2 Commits
Author SHA1 Message Date
cheney 072f304cc2 docs(V0.2): 界面设计同步 2026-10-23 调整 (含日历+打卡合并草案) + 设计文档 §11
Docker Build and Push / build-image (push) Successful in 4m14s
界面设计.html
- 新增 2.12「V0.2.1 界面调整」章节: 头部统一 / 日历·打卡合并 / 模板分类 / 宏观 tab 溢出
- 已实现部分 (①③④) 与待确认草案 (②) 分别标注
- 2.9 宏观截图同步新 tab 顺序与右侧透支提示; 2.3 / 2.7 清单补「模板」分段
- 全部原型头部改用 23px 紧凑头部, 「我」页去掉头部条

设计文档.md
- 新增 §11 界面调整: 变更清单、头部统一对照表、模板接口与排序口径、宏观 tab 顺序与溢出实现、
  待确认的日历+打卡合并方案 (含确认后需改动的文件与样式段)
2026-10-23 16:41:04 +08:00
cheney c765755906 feat(UI): 四 Tab 头部统一 + 清单模板分类 + 宏观 tab 顺序与溢出提示
头部统一
- 新增 PageHeader 组件: 高度 23px (原 NavBar 45px 的一半), 标题 12px 主色加粗, 背景取页面底色
- 打卡 / 宏观 / 监控 三页各自引用, 互不影响; 宏观与监控补齐「宏观」「监控」标题
- 「我」页移除 NavBar 空白条, 蓝色头像区直接贴顶 (不引用 PageHeader, 与其余三页解耦)
- 清理失效样式 .me-navbar

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

宏观 tab
- 顺序改为 报警 → 新闻 → 油价 → 经济数据 → 策略研究 → 经济文章, 默认选中新闻
- /macro 由路由直接重定向到 /macro/news, activeFromPath 兜底同样返回 news
- SwipeTabs 增加右侧溢出提示: 渐变遮罩 + 主色圆底箭头, 可点击向右翻页, 滚到末尾自动消失
- 切换 tab 时自动把激活项滚到可视区中央
2026-10-23 16:40:32 +08:00
13 changed files with 1340 additions and 159 deletions
+645 -23
View File
@@ -187,8 +187,94 @@
.empty .ico{width:64px;height:64px;margin:0 auto 12px;border-radius:50%;background:#f2f3f5;display:flex;align-items:center;justify-content:center;color:#c4c8ce;font-size:30px} .empty .ico{width:64px;height:64px;margin:0 auto 12px;border-radius:50%;background:#f2f3f5;display:flex;align-items:center;justify-content:center;color:#c4c8ce;font-size:30px}
.empty p{font-size:13px;margin-bottom:12px} .empty p{font-size:13px;margin-bottom:12px}
/* ---------- 表格 ---------- */ /* ---------- 统一主 Tab 头部(V0.2.1 调整:高度减半,标题主色小字) ---------- */
table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e6e8eb;border-radius:10px;overflow:hidden;font-size:13px} .navbar-sm{height:23px;margin-top:28px;flex-shrink:0;display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:600;letter-spacing:1px;color:var(--primary);background:var(--bg)}
/* 「我」页:不设头部,头像区直接顶到状态栏下方 */
.me-head.flush{padding-top:24px}
/* ---------- 打卡 · 日历与打卡合并视图(V0.2.1 草案) ---------- */
.mv-cal{background:#fff;border-radius:10px;margin:8px 12px 0;padding:6px 6px 8px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.mv-month{display:flex;align-items:center;gap:8px;padding:2px 4px 6px}
.mv-month b{font-size:14px}
.mv-month .arrow{font-size:17px;color:var(--muted);line-height:1;padding:0 3px}
.mv-month .today{margin-left:auto;font-size:11px;color:var(--primary)}
.mv-grid{display:grid;grid-template-columns:repeat(7,1fr)}
.mv-wd span{text-align:center;font-size:10px;color:var(--muted);padding:1px 0 3px;display:block}
.mv-day{height:31px;margin:1px 0;display:flex;flex-direction:column;align-items:center;justify-content:center;
border-radius:6px;font-size:12px;line-height:1.15;color:var(--text)}
.mv-day.out{color:#c8c8c8}
.mv-day.future{color:#b0b0b0}
.mv-day.today{color:var(--primary);font-weight:700}
.mv-day.sel{background:var(--primary);color:#fff;font-weight:600}
.mv-day i{width:4px;height:4px;border-radius:50%;margin-top:2px}
.mv-day i.partial{background:var(--warn)} .mv-day i.full{background:var(--success)} .mv-day i.over{background:var(--over)}
.mv-day.sel i{background:#fff}
.mv-rate{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--muted);padding:7px 6px 0}
.mv-rate b{color:var(--primary)}
.mv-daybar{display:flex;align-items:center;gap:8px;padding:10px 14px 2px;font-size:13px;font-weight:600}
.mv-daybar .badge{font-weight:400}
.mv-daybar .sum{margin-left:auto;font-size:11px;color:var(--muted);font-weight:400}
.mv-groups{padding-bottom:12px}
.mv-ghead{display:flex;align-items:center;gap:6px;padding:9px 14px 5px;font-size:11px;font-weight:600;color:var(--muted)}
.mv-ghead::before{content:"";width:3px;height:11px;border-radius:2px;background:var(--primary)}
.mv-ghead .cnt{margin-left:auto;font-weight:400;color:var(--weak)}
.mv-card{position:relative;display:flex;align-items:center;gap:9px;background:#fff;border-radius:9px;
padding:8px 10px;margin:0 12px 7px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.mv-card.done{background:#f7fbf8}
.mv-ring{width:42px;height:42px;position:relative;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.mv-ring svg{display:block}
.mv-ring .c{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700}
.mv-ring .c em{font-style:normal;font-size:9px;color:var(--muted);font-weight:400}
.mv-ring .ov{position:absolute;top:-3px;right:-5px;font-size:9px;color:#fff;background:var(--orange);border-radius:999px;padding:0 4px}
.mv-main{flex:1;min-width:0}
.mv-name{font-size:14px;font-weight:600;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mv-meta{font-size:10px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mv-meta .st{color:var(--orange);background:rgba(255,143,31,.12);border-radius:3px;padding:0 4px;margin-left:5px}
.mv-btn{font-size:11px;border-radius:999px;padding:4px 12px;background:var(--primary);color:#fff;flex-shrink:0;white-space:nowrap}
.mv-btn.grey{background:#f2f3f5;color:var(--muted)}
.mv-more{color:#c0c4cc;font-size:15px;line-height:1;padding:4px 2px 4px 6px;flex-shrink:0}
/* 操作面板(⋯ 触发) */
.mv-sheet .sheet-title{font-size:15px;margin-bottom:8px}
.mv-sheet-row{display:flex;align-items:center;justify-content:space-between;padding:12px 4px;border-top:1px solid var(--line);font-size:14px}
.mv-sheet-row.danger{color:var(--danger)}
.mv-sheet-row .k{font-size:11px;color:var(--weak)}
/* ---------- 清单 · 模板分类(V0.2.1 草案) ---------- */
.tpl-card{background:#fff;border-radius:10px;padding:11px 12px;margin:0 12px 10px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.tpl-row{display:flex;align-items:center;gap:7px}
.tpl-name{font-size:14px;font-weight:600}
.tpl-badge{font-size:10px;color:var(--muted);background:#f2f3f5;border-radius:4px;padding:1px 5px}
.tpl-num{margin-left:auto;font-size:11px;color:var(--muted)}
.tpl-chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
.tpl-chip{font-size:10px;color:var(--muted);background:#f7f8fa;border-radius:4px;padding:2px 6px}
.tpl-chip.more{color:var(--primary);background:#f0f5ff}
.tpl-acts{display:flex;gap:14px;margin-top:9px;padding-top:7px;border-top:1px dashed var(--line)}
/* 新建清单(无场景,模板从右侧滑出选择) */
.pick-row{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:14px;color:var(--weak)}
.pick-row .meta{margin-left:auto;display:flex;align-items:center;gap:4px;font-size:11px;color:var(--muted)}
.side-panel{position:absolute;top:28px;right:0;bottom:0;width:78%;background:#fff;z-index:11;
box-shadow:-6px 0 18px rgba(15,23,42,.12);display:flex;flex-direction:column;padding:14px 0 0}
.side-head{display:flex;align-items:center;padding:0 16px 4px}
.side-head b{font-size:16px}
.side-head .x{margin-left:auto;font-size:20px;color:#c8c8c8;line-height:1}
.side-tip{font-size:11px;color:var(--muted);padding:0 16px 8px}
.side-item{display:flex;align-items:baseline;gap:8px;padding:11px 16px}
.side-item.on{background:#f0f5ff}
.side-item .nm{flex:1;font-size:14px;font-weight:600;display:flex;align-items:center;gap:6px}
.side-item.on .nm{color:var(--primary)}
.side-item .sub{font-size:11px;color:var(--muted)}
/* ---------- 宏观 · tab 溢出提示(V0.2.1 草案) ---------- */
.mtabs-wrap{position:relative;background:#fff;border-bottom:1px solid var(--line)}
.mtabs-wrap .mtabs{border-bottom:none}
.mtabs-fade{position:absolute;top:0;right:0;bottom:0;width:52px;display:flex;align-items:center;justify-content:flex-end;
padding-right:8px;background:linear-gradient(to right,rgba(255,255,255,0),#fff 55%,#fff)}
.mtabs-fade .hint{width:19px;height:19px;border-radius:50%;background:#fff;border:1px solid #e3e6ea;
display:flex;align-items:center;justify-content:center;color:var(--primary);font-size:13px;font-weight:700;
box-shadow:0 1px 4px rgba(0,0,0,.14)}
/* ---------- 表格 ---------- */ table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e6e8eb;border-radius:10px;overflow:hidden;font-size:13px}
th,td{border-bottom:1px solid var(--line);padding:9px 12px;text-align:left;vertical-align:middle} th,td{border-bottom:1px solid var(--line);padding:9px 12px;text-align:left;vertical-align:middle}
th{background:#fafbfc;font-weight:600;color:#444;white-space:nowrap} th{background:#fafbfc;font-weight:600;color:#444;white-space:nowrap}
tr:last-child td{border-bottom:none} tr:last-child td{border-bottom:none}
@@ -244,7 +330,7 @@
<p>打卡 / 日历 / 清单 —— 移动端静态高保真原型(设计基线 420 × 820,适配 375~430px)</p> <p>打卡 / 日历 / 清单 —— 移动端静态高保真原型(设计基线 420 × 820,适配 375~430px)</p>
<div class="meta"> <div class="meta">
<span>版本 V0.2(0.2.0)</span><span>分支 dev-0.2</span><span>antd-mobile 风格</span> <span>版本 V0.2(0.2.0)</span><span>分支 dev-0.2</span><span>antd-mobile 风格</span>
<span>底部 4 Tab(打卡默认)</span><span>时区 Asia/Shanghai</span><span>文档日期 2026-10-22</span> <span>底部 4 Tab(打卡默认)</span><span>时区 Asia/Shanghai</span><span>文档日期 2026-10-22 · 修订 2026-10-23</span>
</div> </div>
</div> </div>
@@ -297,14 +383,14 @@
</div> </div>
<!-- ============ 2 主界面 ============ --> <!-- ============ 2 主界面 ============ -->
<h2>2 主界面<span class="sub">三个子视图(SwipeTabs 左右滑动切换)</span></h2> <h2>2 主界面<span class="sub">三个子视图(V0.2 现状;合并方案见 2.12 ②)</span></h2>
<div class="stage"> <div class="stage">
<!-- ---------- 2.1 打卡视图 ---------- --> <!-- ---------- 2.1 打卡视图 ---------- -->
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar">打卡</div> <div class="navbar-sm">打卡</div>
<div class="body"> <div class="body">
<div class="habit-head"> <div class="habit-head">
<div> <div>
@@ -423,7 +509,7 @@
<!-- ---------- 2.2 日历视图 ---------- --> <!-- ---------- 2.2 日历视图 ---------- -->
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar">打卡</div> <div class="navbar-sm">打卡</div>
<div class="body"> <div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px"> <div class="seg" style="width:calc(100% - 28px);margin-left:14px">
<div>打卡</div><div class="on">日历</div><div>清单</div> <div>打卡</div><div class="on">日历</div><div>清单</div>
@@ -525,13 +611,13 @@
<!-- ---------- 2.3 清单视图 ---------- --> <!-- ---------- 2.3 清单视图 ---------- -->
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar">打卡</div> <div class="navbar-sm">打卡</div>
<div class="body"> <div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px"> <div class="seg" style="width:calc(100% - 28px);margin-left:14px">
<div>打卡</div><div>日历</div><div class="on">清单</div> <div>打卡</div><div>日历</div><div class="on">清单</div>
</div> </div>
<div class="list-head"> <div class="list-head">
<div class="seg"><div class="on">进行中</div><div>已归档</div></div> <div class="seg"><div class="on">进行中</div><div>已归档</div><div>模板</div></div>
<div class="mini-btn solid">+ 新建</div> <div class="mini-btn solid">+ 新建</div>
</div> </div>
@@ -589,7 +675,7 @@
<!-- ---------- 2.4 新建打卡项弹窗 ---------- --> <!-- ---------- 2.4 新建打卡项弹窗 ---------- -->
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar">打卡</div> <div class="navbar-sm">打卡</div>
<div class="body"> <div class="body">
<div class="habit-head"> <div class="habit-head">
<div> <div>
@@ -654,7 +740,7 @@
<!-- ---------- 2.5 完成特效 ---------- --> <!-- ---------- 2.5 完成特效 ---------- -->
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar">打卡</div> <div class="navbar-sm">打卡</div>
<div class="body"> <div class="body">
<div class="habit-head"> <div class="habit-head">
<div><div class="habit-date">10月22日 周四</div><div class="habit-summary">已完成 <strong>3</strong> / 4</div></div> <div><div class="habit-date">10月22日 周四</div><div class="habit-summary">已完成 <strong>3</strong> / 4</div></div>
@@ -726,7 +812,7 @@
<!-- ---------- 2.6 日历·明天(计划) ---------- --> <!-- ---------- 2.6 日历·明天(计划) ---------- -->
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar">打卡</div> <div class="navbar-sm">打卡</div>
<div class="body"> <div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px"><div>打卡</div><div class="on">日历</div><div>清单</div></div> <div class="seg" style="width:calc(100% - 28px);margin-left:14px"><div>打卡</div><div class="on">日历</div><div>清单</div></div>
<div class="cal-month-bar"><span class="cal-arrow">‹</span><span class="cal-month">2026年10月</span><span class="cal-arrow">›</span><span class="cal-today">回到今天</span></div> <div class="cal-month-bar"><span class="cal-arrow">‹</span><span class="cal-month">2026年10月</span><span class="cal-arrow">›</span><span class="cal-today">回到今天</span></div>
@@ -792,11 +878,11 @@
<!-- ---------- 2.7 清单完成 + 空态 ---------- --> <!-- ---------- 2.7 清单完成 + 空态 ---------- -->
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar">打卡</div> <div class="navbar-sm">打卡</div>
<div class="body"> <div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px"><div>打卡</div><div>日历</div><div class="on">清单</div></div> <div class="seg" style="width:calc(100% - 28px);margin-left:14px"><div>打卡</div><div>日历</div><div class="on">清单</div></div>
<div class="list-head"> <div class="list-head">
<div class="seg"><div class="on">进行中</div><div>已归档</div></div> <div class="seg"><div class="on">进行中</div><div>已归档</div><div>模板</div></div>
<div class="mini-btn solid">+ 新建</div> <div class="mini-btn solid">+ 新建</div>
</div> </div>
<div class="checklist-card done" style="position:relative;overflow:visible"> <div class="checklist-card done" style="position:relative;overflow:visible">
@@ -839,7 +925,7 @@
<!-- ---------- 2.8 空态与归档 ---------- --> <!-- ---------- 2.8 空态与归档 ---------- -->
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar">打卡</div> <div class="navbar-sm">打卡</div>
<div class="body"> <div class="body">
<div class="habit-head"> <div class="habit-head">
<div><div class="habit-date">10月22日 周四</div><div class="habit-summary">已完成 <strong>0</strong> / 0</div></div> <div><div class="habit-date">10月22日 周四</div><div class="habit-summary">已完成 <strong>0</strong> / 0</div></div>
@@ -871,11 +957,14 @@
<!-- ---------- 2.9 宏观(原首页报警/新闻并入) ---------- --> <!-- ---------- 2.9 宏观(原首页报警/新闻并入) ---------- -->
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar">宏观</div> <div class="navbar-sm">宏观</div>
<div class="body"> <div class="body">
<div class="mtabs"> <div class="mtabs-wrap">
<div>油价</div><div>经济数据</div><div>策略研究</div><div>经济文章</div> <div class="mtabs">
<div>报警<span class="badge-dot">3</span></div><div class="on">新闻</div> <div>报警<span class="badge-dot">3</span></div><div class="on">新闻</div>
<div>油价</div><div>经济数据</div><div>策略研究</div><div>经济文章</div>
</div>
<div class="mtabs-fade"><span class="hint">›</span></div>
</div> </div>
<div class="feed"> <div class="feed">
<div class="feed-card"> <div class="feed-card">
@@ -913,16 +1002,15 @@
</div> </div>
<div class="caption"> <div class="caption">
<b>2.9 宏观 · 原「首页」报警/新闻并入<span class="tag">设计变更</span></b> <b>2.9 宏观 · 原「首页」报警/新闻并入<span class="tag">设计变更</span></b>
<p>「首页」Tab 取消,报警与新闻成为宏观的第 5、6 个子 Tab(<code>/macro/alerts</code>、<code>/macro/news</code>),共 6 个子 Tab 可横向滚动,当前项底部有 3px 主色指示条;报警数量以红色角标挂在子 Tab 上。新闻详情改为 <code>/macro/news/:id</code>,底部「宏观」保持高亮。</p> <p>「首页」Tab 取消,报警与新闻并入宏观。<b>2026-10-23 调整</b>:两者移到<b>最左</b>(报警 → 新闻 → 油价 → 经济数据 → 策略研究 → 经济文章),<b>默认选中「新闻」</b>;共 6 个子 Tab 可横向滚动,当前项底部有 3px 主色指示条;报警数量以红色角标挂在子 Tab 上。tab 条右侧增加<b>渐变遮罩 + 箭头</b>,提示还有未展示的 tab(详见 2.12 ④)。新闻详情为 <code>/macro/news/:id</code>,底部「宏观」保持高亮。</p>
</div> </div>
</div> </div>
<!-- ---------- 2.10 我的 · OTA(开发版本 关 = 默认) ---------- --> <!-- ---------- 2.10 我的 · OTA(开发版本 关 = 默认) ---------- -->
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar">我</div>
<div class="body"> <div class="body">
<div class="me-head"> <div class="me-head flush">
<div class="me-avatar">C</div> <div class="me-avatar">C</div>
<div class="me-name">Cheney</div> <div class="me-name">Cheney</div>
<div class="me-mail">qian.huang@sunyard.com</div> <div class="me-mail">qian.huang@sunyard.com</div>
@@ -964,9 +1052,8 @@
<!-- ---------- 2.11 我的 · OTA(开发版本 开) ---------- --> <!-- ---------- 2.11 我的 · OTA(开发版本 开) ---------- -->
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar">我</div>
<div class="body"> <div class="body">
<div class="me-head"> <div class="me-head flush">
<div class="me-avatar">C</div> <div class="me-avatar">C</div>
<div class="me-name">Cheney</div> <div class="me-name">Cheney</div>
<div class="me-mail">qian.huang@sunyard.com</div> <div class="me-mail">qian.huang@sunyard.com</div>
@@ -1008,9 +1095,544 @@
</div> </div>
<!-- ============ 2.12 V0.2.1 界面调整草案 ============ -->
<h2>2.12 V0.2.1 界面调整<span class="sub">头部统一 / 日历·打卡合并 / 模板分类 / 宏观 tab 溢出</span></h2>
<p class="desc">本节为 2026-10-23 提出的调整方案。其中 <b>①头部统一 / ③模板分类 / ④宏观 tab 溢出</b> 已按本节实现;
<b>②日历与打卡合并</b> 为待确认草案,<b>确认后再改代码</b>。</p>
<h3>① 四个主 Tab 头部统一</h3>
<table>
<tr><th>页</th><th>调整前</th><th>调整后</th></tr>
<tr><td>打卡</td><td>NavBar 45px,标题「打卡」17px 深色</td><td><b>23px</b>,标题「打卡」12px 主色加粗,字距 1px</td></tr>
<tr><td>宏观</td><td>NavBar 45px,<b>无标题</b>(空白)</td><td>23px + 标题「宏观」(同打卡规格)</td></tr>
<tr><td>监控</td><td>NavBar 45px,<b>无标题</b>(空白)</td><td>23px + 标题「监控」(同打卡规格)</td></tr>
<tr><td>我</td><td>NavBar 45px 空白条</td><td><b>整条去掉</b>,蓝色头像区直接顶到状态栏下方</td></tr>
</table>
<p class="note">实现为独立组件 <code>PageHeader</code>,只被打卡 / 宏观 / 监控 引用;「我」页不引用,因此三者互不影响。
头部背景取页面底色 <code>#f5f5f5</code>(不再是卡片白),视觉上更轻。</p>
<div class="stage">
<div class="shot">
<div class="phone">
<div class="navbar-sm">打卡</div>
<div class="body">
<div class="habit-head">
<div><div class="habit-date">10月23日 周五</div><div class="habit-summary">已完成 <strong>2</strong> / 10</div></div>
<div class="head-right"><div class="mini-btn solid">+ 新建</div></div>
</div>
<div class="habit-list">
<div class="habit-card">
<div class="ring">
<svg width="58" height="58" viewBox="0 0 58 58">
<circle cx="29" cy="29" r="26" fill="none" stroke="#ececf0" stroke-width="6"/>
<circle cx="29" cy="29" r="26" fill="none" stroke="#1677ff" stroke-width="6" stroke-linecap="round" stroke-dasharray="163.4" stroke-dashoffset="81.7" transform="rotate(-90 29 29)"/>
</svg><div class="ring-center">1<em>/2</em></div>
</div>
<div class="habit-main"><div class="habit-name">喝水</div><div class="habit-meta">每天 2 次<span class="streak">连续 12</span></div><div class="habit-range">今日</div></div>
<div class="habit-actions"><div class="mini-btn solid">打卡</div></div>
<div class="habit-foot"><span class="link">撤销</span><span class="link">编辑</span><span class="link danger">归档</span></div>
</div>
<div class="habit-card">
<div class="ring">
<svg width="58" height="58" viewBox="0 0 58 58">
<circle cx="29" cy="29" r="26" fill="none" stroke="#ececf0" stroke-width="6"/>
<circle cx="29" cy="29" r="26" fill="none" stroke="#ff8f1f" stroke-width="6" stroke-linecap="round" stroke-dasharray="163.4" stroke-dashoffset="163.4" transform="rotate(-90 29 29)"/>
</svg><div class="ring-center">0<em>/1</em></div>
</div>
<div class="habit-main"><div class="habit-name">俯卧撑</div><div class="habit-meta">每周 1 次</div><div class="habit-range">10-19 ~ 10-25</div></div>
<div class="habit-actions"><div class="mini-btn solid">打卡</div></div>
<div class="habit-foot"><span class="link">编辑</span><span class="link danger">归档</span></div>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>①-a 打卡(头部已缩至 23px)<span class="tag">已实现</span></b>
<p>原 45px 白色 NavBar 换成 23px 底色条 + 主色标题,页面上移,首屏多出一张卡片的高度。</p>
</div>
</div>
<div class="shot">
<div class="phone">
<div class="navbar-sm">宏观</div>
<div class="body">
<div class="mtabs-wrap">
<div class="mtabs">
<div>报警<span class="badge-dot">1</span></div><div class="on">新闻</div><div>油价</div>
<div>经济数据</div><div>策略研究</div><div>经济文章</div>
</div>
<div class="mtabs-fade"><span class="hint">›</span></div>
</div>
<div class="feed">
<div class="feed-card">
<div class="feed-title">央行开展 5000 亿元 MLF 操作,中标利率持平</div>
<div class="feed-sub">本月 MLF 净投放 2000 亿元,市场流动性保持合理充裕…</div>
<div class="feed-meta"><span>妙想财经</span><span>2026-10-22</span><span class="rk">#1</span></div>
</div>
<div class="feed-card">
<div class="feed-title">9 月社零同比增长 3.2%,服务消费延续回暖</div>
<div class="feed-sub">餐饮收入增速回升至 5.1%,可选消费分化明显…</div>
<div class="feed-meta"><span>妙想财经</span><span>2026-10-21</span><span class="rk">#2</span></div>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>①-b + ④ 宏观:头部标题化 · 报警/新闻置最左 · 右侧溢出提示<span class="tag">已实现</span></b>
<p><b>顺序</b>:报警 → 新闻 → 油价 → 经济数据 → 策略研究 → 经济文章,<b>默认选中「新闻」</b>(<code>/macro</code> 自动跳 <code>/macro/news</code>)。<br>
<b>溢出提示</b>:tab 条仍可向右滚动时,右边缘出现白色渐变遮罩 + 主色圆底箭头「›」(1.5s 轻微左右摆动,可点击向右翻页),滚到末尾自动消失;切换 tab 时自动把激活项滚到可视区中间。<br>
antd-mobile Tabs 自带的右侧渐变遮罩只有 30px 且无箭头,用户难以察觉,故叠加此提示层。</p>
</div>
</div>
</div>
<h3>② 打卡:日历 + 打卡合并(<span style="color:var(--danger)">待确认草案</span>)</h3>
<p class="desc">把原来的「打卡」「日历」两个 tab 合成一个:<b>上半部日历,下半部打卡卡片</b>。取舍按下述规则:</p>
<table>
<tr><th>项</th><th>规则</th></tr>
<tr><td>页面结构</td><td>SwipeTabs 从 3 个减为 <b>2 个</b>:<b>日历(含打卡)</b> / <b>清单</b></td></tr>
<tr><td>上方日历</td><td>保留月历网格 + 月份左右切换 + 「回到今天」;<b>去掉</b>「昨天 / 今天 / 明天」分段控件(点日期即可,节省 38px 高度)</td></tr>
<tr><td>日历高度</td><td>日期格子 38px → <b>31px</b>,去掉 6 行补位中的空行;整块约 240px</td></tr>
<tr><td>下方打卡</td><td>沿用现有打卡卡片样式,但<b>紧凑化</b>:内边距 12px → 8px,环形 58px → <b>42px</b>(环宽 6 → 5),行高压缩,卡片间距 10 → 7px</td></tr>
<tr><td>卡片内容</td><td>保留环形进度(含达标对勾 / 超额 +n)、名称、周期与目标、连续天数、打卡按钮;<b>撤销 / 编辑 / 归档收进「⋯」</b>,减少一行高度</td></tr>
<tr><td>分组</td><td>按周期类型分组:<b>每天 → 每周 → 每月</b>,每组一个小标题(左侧主色竖条 + 组名 + 条数);<b>去掉「每年」分组</b>(已有年目标项并入「每月」区末尾的「其他周期」小标题,或建议直接隐藏年视图)</td></tr>
<tr><td>联动</td><td>点日历某日 → 下方卡片即该日数据(进度仍按该日所属周期算);未来日期只读,按钮变灰显示「计划」</td></tr>
<tr><td>空态</td><td>日历下方直接显示「该日没有打卡项」+「+ 新建」引导</td></tr>
</table>
<div class="stage">
<div class="shot">
<div class="phone">
<div class="navbar-sm">打卡</div>
<div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px">
<div class="on">日历</div><div>清单</div>
</div>
<div class="mv-cal">
<div class="mv-month">
<span class="arrow">‹</span><b>2026年10月</b><span class="arrow">›</span>
<span class="today">回到今天</span>
</div>
<div class="mv-grid mv-wd"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div>
<div class="mv-grid">
<div class="mv-day out">29</div><div class="mv-day out">30</div>
<div class="mv-day">1<i class="full"></i></div><div class="mv-day">2<i class="full"></i></div>
<div class="mv-day">3<i class="partial"></i></div><div class="mv-day">4<i class="full"></i></div><div class="mv-day">5</div>
<div class="mv-day">6<i class="full"></i></div><div class="mv-day">7<i class="over"></i></div>
<div class="mv-day">8<i class="full"></i></div><div class="mv-day">9<i class="partial"></i></div>
<div class="mv-day">10<i class="full"></i></div><div class="mv-day">11<i class="full"></i></div><div class="mv-day">12</div>
<div class="mv-day">13</div><div class="mv-day">14<i class="full"></i></div><div class="mv-day">15<i class="full"></i></div>
<div class="mv-day">16<i class="partial"></i></div><div class="mv-day">17<i class="full"></i></div><div class="mv-day">18<i class="full"></i></div><div class="mv-day">19<i class="full"></i></div>
<div class="mv-day">20<i class="full"></i></div><div class="mv-day">21<i class="full"></i></div>
<div class="mv-day today sel">22<i class="partial"></i></div><div class="mv-day future">23</div><div class="mv-day future">24</div><div class="mv-day future">25</div>
<div class="mv-day future">26</div><div class="mv-day future">27</div><div class="mv-day future">28</div>
<div class="mv-day future">29</div><div class="mv-day future">30</div><div class="mv-day future">31</div><div class="mv-day out">1</div>
</div>
<div class="mv-rate"><span>本月完成率 <b>82%</b></span><span>今天 · 2026-10-22</span></div>
</div>
<div class="mv-daybar">今天<span class="badge">已完成 2 / 4</span><span class="sum">点环即打卡</span></div>
<div class="mv-groups">
<div class="mv-ghead">每天<span class="cnt">2 项</span></div>
<div class="mv-card">
<div class="mv-ring">
<svg width="42" height="42" viewBox="0 0 42 42">
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#ececf0" stroke-width="5"/>
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#1677ff" stroke-width="5" stroke-linecap="round" stroke-dasharray="116.2" stroke-dashoffset="58.1" transform="rotate(-90 21 21)"/>
</svg>
<div class="c">1<em>/2</em></div>
</div>
<div class="mv-main">
<div class="mv-name">喝水</div>
<div class="mv-meta">每天 2 次 · 今日 1/2<span class="st">连续 12</span></div>
</div>
<div class="mv-btn">打卡</div>
<div class="mv-more">⋯</div>
</div>
<div class="mv-card done">
<div class="mv-ring">
<svg width="42" height="42" viewBox="0 0 42 42">
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#ececf0" stroke-width="5"/>
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#a855f7" stroke-width="5" stroke-linecap="round" stroke-dasharray="116.2" stroke-dashoffset="0" transform="rotate(-90 21 21)"/>
</svg>
<div class="c"><svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#a855f7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<span class="ov">+1</span>
</div>
<div class="mv-main">
<div class="mv-name">吃药</div>
<div class="mv-meta">每天 1 次 · 今日 2/1<span class="st">连续 30</span></div>
</div>
<div class="mv-btn grey">再打一次</div>
<div class="mv-more">⋯</div>
</div>
<div class="mv-ghead">每周<span class="cnt">1 项</span></div>
<div class="mv-card">
<div class="mv-ring">
<svg width="42" height="42" viewBox="0 0 42 42">
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#ececf0" stroke-width="5"/>
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#ff8f1f" stroke-width="5" stroke-linecap="round" stroke-dasharray="116.2" stroke-dashoffset="77.5" transform="rotate(-90 21 21)"/>
</svg>
<div class="c">1<em>/3</em></div>
</div>
<div class="mv-main">
<div class="mv-name">健身</div>
<div class="mv-meta">每周 3 次 · 本周 1/3<span class="st">连续 5</span></div>
</div>
<div class="mv-btn">打卡</div>
<div class="mv-more">⋯</div>
</div>
<div class="mv-ghead">每月<span class="cnt">1 项</span></div>
<div class="mv-card">
<div class="mv-ring">
<svg width="42" height="42" viewBox="0 0 42 42">
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#ececf0" stroke-width="5"/>
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#00b578" stroke-width="5" stroke-linecap="round" stroke-dasharray="116.2" stroke-dashoffset="116.2" transform="rotate(-90 21 21)"/>
</svg>
<div class="c">0<em>/12</em></div>
</div>
<div class="mv-main">
<div class="mv-name">读书</div>
<div class="mv-meta">每月 12 次 · 本月 0/12</div>
</div>
<div class="mv-btn">打卡</div>
<div class="mv-more">⋯</div>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>②-a 合并后的主视图(日历在上 · 打卡在下,按天 / 周 / 月分组)<span class="tag" style="background:#fff1f0;color:#ff3141">待确认</span></b>
<p>卡片整体压缩:环形 42px、内边距 8px、行距 7px,单卡高度约 58px(原 92px),一屏可看 4~5 项。分组顺序固定为 <b>每天 → 每周 → 每月</b>,
组标题左侧 3px 主色竖条;<b>无「每年」分组</b>。日历顶部的「昨天 / 今天 / 明天」分段已去掉,直接点日期切换,选中日靠「今天」主色数字与蓝色实心块识别。</p>
</div>
</div>
<div class="shot">
<div class="phone">
<div class="navbar-sm">打卡</div>
<div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px"><div class="on">日历</div><div>清单</div></div>
<div class="mv-cal">
<div class="mv-month"><span class="arrow">‹</span><b>2026年10月</b><span class="arrow">›</span><span class="today">回到今天</span></div>
<div class="mv-grid mv-wd"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div>
<div class="mv-grid">
<div class="mv-day out">29</div><div class="mv-day out">30</div>
<div class="mv-day">1<i class="full"></i></div><div class="mv-day">2<i class="full"></i></div>
<div class="mv-day">3<i class="partial"></i></div><div class="mv-day">4<i class="full"></i></div><div class="mv-day">5</div>
<div class="mv-day">6<i class="full"></i></div><div class="mv-day">7<i class="over"></i></div>
<div class="mv-day">8<i class="full"></i></div><div class="mv-day">9<i class="partial"></i></div>
<div class="mv-day">10<i class="full"></i></div><div class="mv-day">11<i class="full"></i></div><div class="mv-day">12</div>
<div class="mv-day">13</div><div class="mv-day">14<i class="full"></i></div><div class="mv-day">15<i class="full"></i></div>
<div class="mv-day">16<i class="partial"></i></div><div class="mv-day">17<i class="full"></i></div><div class="mv-day">18<i class="full"></i></div><div class="mv-day">19<i class="full"></i></div>
<div class="mv-day">20<i class="full"></i></div><div class="mv-day">21<i class="full"></i></div>
<div class="mv-day today">22<i class="partial"></i></div><div class="mv-day future sel">23</div><div class="mv-day future">24</div><div class="mv-day future">25</div>
<div class="mv-day future">26</div><div class="mv-day future">27</div><div class="mv-day future">28</div>
<div class="mv-day future">29</div><div class="mv-day future">30</div><div class="mv-day future">31</div><div class="mv-day out">1</div>
</div>
<div class="mv-rate"><span>本月完成率 <b>82%</b></span><span>明天 · 2026-10-23</span></div>
</div>
<div class="mv-daybar">明天<span class="badge">计划(不可打卡)</span></div>
<div class="mv-groups">
<div class="mv-ghead">每天<span class="cnt">2 项</span></div>
<div class="mv-card">
<div class="mv-ring">
<svg width="42" height="42" viewBox="0 0 42 42">
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#ececf0" stroke-width="5"/>
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#1677ff" stroke-width="5" stroke-linecap="round" stroke-dasharray="116.2" stroke-dashoffset="116.2" transform="rotate(-90 21 21)"/>
</svg><div class="c">0<em>/2</em></div>
</div>
<div class="mv-main"><div class="mv-name">喝水</div><div class="mv-meta">每天 2 次 · 当日 0/2</div></div>
<div class="mv-btn grey">计划</div><div class="mv-more">⋯</div>
</div>
<div class="mv-card">
<div class="mv-ring">
<svg width="42" height="42" viewBox="0 0 42 42">
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#ececf0" stroke-width="5"/>
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#a855f7" stroke-width="5" stroke-linecap="round" stroke-dasharray="116.2" stroke-dashoffset="116.2" transform="rotate(-90 21 21)"/>
</svg><div class="c">0<em>/1</em></div>
</div>
<div class="mv-main"><div class="mv-name">吃药</div><div class="mv-meta">每天 1 次 · 当日 0/1</div></div>
<div class="mv-btn grey">计划</div><div class="mv-more">⋯</div>
</div>
<div class="mv-ghead">每周<span class="cnt">1 项</span></div>
<div class="mv-card">
<div class="mv-ring">
<svg width="42" height="42" viewBox="0 0 42 42">
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#ececf0" stroke-width="5"/>
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#ff8f1f" stroke-width="5" stroke-linecap="round" stroke-dasharray="116.2" stroke-dashoffset="77.5" transform="rotate(-90 21 21)"/>
</svg><div class="c">1<em>/3</em></div>
</div>
<div class="mv-main"><div class="mv-name">健身</div><div class="mv-meta">每周 3 次 · 本周 1/3</div></div>
<div class="mv-btn grey">计划</div><div class="mv-more">⋯</div>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>②-b 未来日期(计划态)</b>
<p>选中未来日期时,打卡按钮统一变灰显示「计划」,点击不生效;环形仍显示该日所属周期的既有进度(此处举例为周期初)。
补打窗口仍是最近 7 天,超出时同样只读。</p>
</div>
</div>
<div class="shot">
<div class="phone">
<div class="navbar-sm">打卡</div>
<div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px"><div class="on">日历</div><div>清单</div></div>
<div class="mv-cal">
<div class="mv-month"><span class="arrow">‹</span><b>2026年10月</b><span class="arrow">›</span><span class="today">回到今天</span></div>
<div class="mv-grid mv-wd"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div>
<div class="mv-grid">
<div class="mv-day out">29</div><div class="mv-day out">30</div>
<div class="mv-day">1<i class="full"></i></div><div class="mv-day">2<i class="full"></i></div>
<div class="mv-day">3<i class="partial"></i></div><div class="mv-day">4<i class="full"></i></div><div class="mv-day">5</div>
<div class="mv-day">6<i class="full"></i></div><div class="mv-day">7<i class="over"></i></div>
<div class="mv-day">8<i class="full"></i></div><div class="mv-day">9<i class="partial"></i></div>
<div class="mv-day">10<i class="full"></i></div><div class="mv-day">11<i class="full"></i></div><div class="mv-day">12</div>
<div class="mv-day">13</div><div class="mv-day">14<i class="full"></i></div><div class="mv-day">15<i class="full"></i></div>
<div class="mv-day">16<i class="partial"></i></div><div class="mv-day">17<i class="full"></i></div><div class="mv-day">18<i class="full"></i></div><div class="mv-day">19<i class="full"></i></div>
<div class="mv-day">20<i class="full"></i></div><div class="mv-day">21<i class="full"></i></div>
<div class="mv-day today">22<i class="partial"></i></div><div class="mv-day future">23</div><div class="mv-day future">24</div><div class="mv-day future">25</div>
<div class="mv-day future">26</div><div class="mv-day future">27</div><div class="mv-day future">28</div>
<div class="mv-day future">29</div><div class="mv-day future">30</div><div class="mv-day future">31</div><div class="mv-day out">1</div>
</div>
<div class="mv-rate"><span>本月完成率 <b>82%</b></span><span>今天 · 2026-10-22</span></div>
</div>
<div class="mv-daybar">今天<span class="badge">已完成 2 / 4</span><span class="sum">点环即打卡</span></div>
<div class="mv-groups">
<div class="mv-ghead">每天<span class="cnt">2 项</span></div>
<div class="mv-card">
<div class="mv-ring">
<svg width="42" height="42" viewBox="0 0 42 42">
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#ececf0" stroke-width="5"/>
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#1677ff" stroke-width="5" stroke-linecap="round" stroke-dasharray="116.2" stroke-dashoffset="58.1" transform="rotate(-90 21 21)"/>
</svg><div class="c">1<em>/2</em></div>
</div>
<div class="mv-main"><div class="mv-name">喝水</div><div class="mv-meta">每天 2 次 · 今日 1/2</div></div>
<div class="mv-btn">打卡</div><div class="mv-more">⋯</div>
</div>
<div class="mv-card done">
<div class="mv-ring">
<svg width="42" height="42" viewBox="0 0 42 42">
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#ececf0" stroke-width="5"/>
<circle cx="21" cy="21" r="18.5" fill="none" stroke="#a855f7" stroke-width="5" stroke-linecap="round" stroke-dasharray="116.2" stroke-dashoffset="0" transform="rotate(-90 21 21)"/>
</svg>
<div class="c"><svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#a855f7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<span class="ov">+1</span>
</div>
<div class="mv-main"><div class="mv-name">吃药</div><div class="mv-meta">每天 1 次 · 今日 2/1</div></div>
<div class="mv-btn grey">再打一次</div><div class="mv-more">⋯</div>
</div>
</div>
</div>
<div class="mask" style="inset:28px 0 0 0"></div>
<div class="sheet mv-sheet">
<div class="sheet-title">吃药 · 每天 1 次</div>
<div class="mv-sheet-row"><span>撤销上次打卡</span><span class="k">今日 2 次</span></div>
<div class="mv-sheet-row"><span>编辑打卡项</span><span class="k">名称 / 周期 / 目标</span></div>
<div class="mv-sheet-row danger"><span>归档</span><span class="k">历史记录保留</span></div>
<div class="btn-ghost" style="margin-top:10px">取消</div>
</div>
<div class="tabbar">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>②-c 卡片「⋯」操作面板</b>
<p>紧凑化后卡片不再常显三个文字链接,改由右上角「⋯」唤出底部面板:撤销 / 编辑 / 归档。面板项 44px 高,
「归档」用危险色;撤销仅在当日有记录时可用,否则置灰。</p>
</div>
</div>
</div>
<h3>③ 清单:新增「模板」分类(与进行中 / 已归档并列)<span class="tag">已实现</span></h3>
<p class="desc">清单页分段从 2 个变为 <b>3 个</b>:<b>进行中 / 已归档 / 模板</b>。模板拥有独立的增删改查,<b>按名称字母顺序</b>(中文按拼音、数字按数值)排序;
新建清单时<b>只保留名称</b>,<b>去掉场景</b>,模板改为点击后在<b>右侧滑出</b>的面板里选择。</p>
<div class="stage">
<div class="shot">
<div class="phone">
<div class="navbar-sm">打卡</div>
<div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px"><div>日历</div><div class="on">清单</div></div>
<div class="list-head">
<div class="seg"><div>进行中</div><div>已归档</div><div class="on">模板</div></div>
<div class="mini-btn solid">+ 新建模板</div>
</div>
<div class="tpl-card">
<div class="tpl-row"><span class="tpl-name">出差</span><span class="tpl-badge">预置</span><span class="tpl-num">11 项</span></div>
<div class="tpl-chips">
<span class="tpl-chip">身份证 / 证件</span><span class="tpl-chip">手机充电器 + 充电宝</span>
<span class="tpl-chip">笔记本电脑 + 电源</span><span class="tpl-chip">耳机</span><span class="tpl-chip more">+7</span>
</div>
<div class="tpl-acts"><span class="link">由此新建</span></div>
</div>
<div class="tpl-card">
<div class="tpl-row"><span class="tpl-name">回老家</span><span class="tpl-badge">预置</span><span class="tpl-num">12 项</span></div>
<div class="tpl-chips">
<span class="tpl-chip">身份证 / 证件</span><span class="tpl-chip">钥匙</span>
<span class="tpl-chip">手机充电器 + 充电宝</span><span class="tpl-chip">换洗衣物</span><span class="tpl-chip more">+8</span>
</div>
<div class="tpl-acts"><span class="link">由此新建</span></div>
</div>
<div class="tpl-card">
<div class="tpl-row"><span class="tpl-name">露营装备</span><span class="tpl-num">2 项</span></div>
<div class="tpl-chips"><span class="tpl-chip">帐篷</span><span class="tpl-chip">睡袋</span></div>
<div class="tpl-acts"><span class="link">由此新建</span><span class="link">编辑</span><span class="link danger">删除</span></div>
</div>
</div>
<div class="tabbar">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>③-a 模板分类<span class="tag">已实现</span></b>
<p>模板卡片:名称 + 「预置」标记 + 项数;下方最多展示 4 个检查项摘要(超出折叠为 <code>+n</code>);底部操作「由此新建 / 编辑 / 删除」。
<b>系统预置模板不可编辑、不可删除</b>(只保留「由此新建」)。右上角按钮随分段切换:模板页为「+ 新建模板」,其余为「+ 新建」。</p>
</div>
</div>
<div class="shot">
<div class="phone">
<div class="navbar-sm">打卡</div>
<div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px"><div>日历</div><div class="on">清单</div></div>
<div class="mask" style="inset:28px 0 0 0"></div>
<div class="sheet">
<div class="sheet-title">新建检查清单</div>
<div class="field"><label>名称</label><div class="inp">周末露营</div></div>
<div class="field">
<label>模板</label>
<div class="pick-row">露营装备<span class="meta">2 项 <span style="color:#c8c8c8;font-size:15px">›</span></span></div>
</div>
<div class="btn-primary">创建</div>
<div class="btn-ghost">取消</div>
</div>
<div class="side-panel">
<div class="side-head"><b>选择模板</b><span class="x">×</span></div>
<div class="side-tip">按名称字母顺序排列</div>
<div class="side-item"><span class="nm">不使用模板</span><span class="sub">空白清单</span></div>
<div class="side-item"><span class="nm">出差<span class="tpl-badge">预置</span></span><span class="sub">11 项</span></div>
<div class="side-item"><span class="nm">回老家<span class="tpl-badge">预置</span></span><span class="sub">12 项</span></div>
<div class="side-item on"><span class="nm">露营装备</span><span class="sub">2 项</span></div>
</div>
</div>
<div class="tabbar">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>③-b 新建清单 + 模板右侧滑出面板<span class="tag">已实现</span></b>
<p>表单只剩两项:<b>名称</b>(≤20 字,必填)与 <b>模板</b>(可空)。<b>场景选择器已移除</b>——场景不再由用户填写,新建时沿用所选模板自带的场景(无模板则为空),历史数据仍正常展示场景标签。<br>
点模板行从<b>右侧滑出 78% 宽面板</b>:首项固定为「不使用模板」,其后按字母顺序列出全部模板(预置带标记、右侧显示项数),选中项主色高亮,点遮罩或 × 关闭。</p>
</div>
</div>
<div class="shot">
<div class="phone">
<div class="navbar-sm">打卡</div>
<div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px"><div>日历</div><div class="on">清单</div></div>
<div class="mask" style="inset:28px 0 0 0"></div>
<div class="sheet">
<div class="sheet-title">编辑模板</div>
<div class="field"><label>模板名称</label><div class="inp">露营装备</div></div>
<div class="field">
<label>检查项(3)</label>
<div style="display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:8px"><span style="width:16px;text-align:right;font-size:11px;color:#999">1</span><div class="inp" style="flex:1">帐篷</div><span style="color:#ccc;font-size:17px">×</span></div>
<div style="display:flex;align-items:center;gap:8px"><span style="width:16px;text-align:right;font-size:11px;color:#999">2</span><div class="inp" style="flex:1">睡袋</div><span style="color:#ccc;font-size:17px">×</span></div>
<div style="display:flex;align-items:center;gap:8px"><span style="width:16px;text-align:right;font-size:11px;color:#999">3</span><div class="inp ph" style="flex:1">检查项内容</div><span style="color:#ccc;font-size:17px">×</span></div>
</div>
<div style="margin-top:8px;border:1px solid var(--line);border-radius:7px;text-align:center;padding:6px 0;font-size:12px;color:var(--muted)">+ 添加检查项</div>
</div>
<div class="btn-primary">保存</div>
<div class="btn-ghost">取消</div>
</div>
</div>
<div class="tabbar">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>③-c 模板编辑(增删改)<span class="tag">已实现</span></b>
<p>新建 / 编辑共用同一弹层:模板名称(≤20 字)+ 检查项列表(逐条 ≤40 字,可删除、可追加)。保存时至少需 1 条有效检查项,名称为空或全空则拦截并 Toast 提示。
删除模板需二次确认,且不影响已用该模板创建过的清单。</p>
</div>
</div>
</div>
<h3>④ 补充:模板接口</h3>
<table>
<tr><th>方法</th><th>路径</th><th>说明</th></tr>
<tr><td>GET</td><td><code>/api/checklists/templates</code></td><td>模板列表(含预置),<b>服务端按名称字母顺序排序</b></td></tr>
<tr><td>GET</td><td><code>/api/checklists/templates/:id</code></td><td>模板详情</td></tr>
<tr><td>POST</td><td><code>/api/checklists/templates</code></td><td>新建模板 <code>{ name, items[] }</code></td></tr>
<tr><td>PUT</td><td><code>/api/checklists/templates/:id</code></td><td>改名称 / 检查项(预置模板拒绝)</td></tr>
<tr><td>DELETE</td><td><code>/api/checklists/templates/:id</code></td><td>删自建模板(预置模板拒绝)</td></tr>
</table>
<p class="note">排序口径:<code>Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })</code>,中文按拼音、数字按数值比较,前后端一致。</p>
<!-- ============ 3 组件状态 ============ --> <!-- ============ 3 组件状态 ============ -->
<h2>3 组件状态定义<span class="sub">Component States</span></h2> <h2>3 组件状态定义<span class="sub">Component States</span></h2>
<h3>3.0 页面头部(PageHeader)</h3>
<table>
<tr><th>项</th><th>取值</th></tr>
<tr><td>高度</td><td>23px(原 NavBar 45px 的一半),<code>flex: 0 0 23px</code> 不参与压缩</td></tr>
<tr><td>标题字号 / 字重</td><td>12px / 600</td></tr>
<tr><td>标题颜色</td><td>主色 <code>#1677ff</code></td></tr>
<tr><td>背景</td><td>页面底色 <code>#f5f5f5</code></td></tr>
<tr><td>适用范围</td><td>打卡 / 宏观 / 监控;<b>「我」页不使用</b></td></tr>
</table>
<p class="note">实现为独立组件 <code>src/components/PageHeader.jsx</code>,三页各自引用、互不影响;「我」页移除 NavBar 后头像区直接贴顶。</p>
<h3>3.1 环形进度(尺寸 58,环宽 6,r=26,周长 163.4)</h3> <h3>3.1 环形进度(尺寸 58,环宽 6,r=26,周长 163.4)</h3>
<table> <table>
<tr><th>状态</th><th>条件</th><th>环形</th><th>中心</th><th>卡片底</th><th>按钮</th></tr> <tr><th>状态</th><th>条件</th><th>环形</th><th>中心</th><th>卡片底</th><th>按钮</th></tr>
+72
View File
@@ -442,3 +442,75 @@ compareVersion('0.2','0.2.0') // → 0 (缺位补 0)
### 10.5 界面设计同步 ### 10.5 界面设计同步
`界面设计.html` 已同步:底部 Tab 改为 4 项且「打卡」高亮在首位、打卡页去掉特效开关按钮、新增「我的 · OTA 开发版本开关」屏、宏观新增「报警 / 新闻」子 Tab 与合并后的 6 Tab 导航。 `界面设计.html` 已同步:底部 Tab 改为 4 项且「打卡」高亮在首位、打卡页去掉特效开关按钮、新增「我的 · OTA 开发版本开关」屏、宏观新增「报警 / 新闻」子 Tab 与合并后的 6 Tab 导航。
---
## 11. 界面调整(2026-10-23)
### 11.1 变更清单
| # | 范围 | 变更 | 状态 |
| --- | --- | --- | --- |
| 1 | 页面头部 | 打卡 NavBar 高度减半(45 → 23px)、标题改主色 12px;宏观 / 监控补标题;「我」页整条去掉 | **已实现** |
| 2 | 打卡页 | 日历与打卡合并为一个视图(上日历、下打卡,按天/周/月分组、去掉年分组、卡片紧凑化) | **待确认**(设计稿已出,见《界面设计.html》2.12 ②节) |
| 3 | 清单页 | 新增「模板」分类(与进行中 / 已归档并列),独立 CRUD + 字母序;新建清单去掉场景、模板改右侧滑出选择 | **已实现** |
| 4 | 宏观页 | 报警 / 新闻移到最左并默认选中新闻;tab 条增加右侧溢出提示 | **已实现** |
### 11.2 页面头部统一
新增组件 `src/components/PageHeader.jsx`(高度 23px、标题 12px/600 主色、字距 1px、背景取页面底色 `#f5f5f5`),只被 **打卡 / 宏观 / 监控** 三页引用:
| 页 | 变更前 | 变更后 |
| --- | --- | --- |
| 打卡 | `NavBar` 45px,标题 17px 深色 | `PageHeader` 23px,标题主色 |
| 宏观 | `NavBar` 45px,空白 | `PageHeader` + 标题「宏观」 |
| 监控 | `NavBar` 45px,空白 | `PageHeader` + 标题「监控」 |
| 我 | `NavBar` 45px 空白条 | **移除**,`me-profile` 直接贴顶 |
> 「我」页不引用该组件,因此调整打卡/宏观/监控的头部不会波及「我」;反之亦然。三个页面之间也各自持有独立实例,互不影响。
### 11.3 清单模板分类
**接口**(`server/routes/checklists.js`):
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/checklists/templates` | 列表,**服务端按名称字母顺序排序**(原为 `builtin desc, id asc`) |
| GET | `/api/checklists/templates/:id` | 详情(新增) |
| POST | `/api/checklists/templates` | 新建(名称 ≤20 字,≥1 条检查项) |
| PUT | `/api/checklists/templates/:id` | 改名称 / 检查项(新增;`builtin=true` 拒绝,404) |
| DELETE | `/api/checklists/templates/:id` | 删自建模板(`builtin=true` 拒绝) |
排序口径:`Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })` —— 中文按拼音、数字按数值。前端 `ListView` 拿到列表后再排一次,保证与后端一致(网络返回顺序不可控时也不乱)。
**前端**(`src/pages/checkin/ListView.jsx`):
- 分段由 2 项扩为 3 项:`active`(进行中)/ `archived`(已归档)/ `template`(模板);右上角按钮随分段切换为「+ 新建」/「+ 新建模板」。
- 模板卡片:名称 + 「预置」标记 + 项数;最多展示 4 条检查项摘要,超出折叠为 `+n`;操作「由此新建 / 编辑 / 删除」,预置模板只保留「由此新建」。
- 新建 / 编辑模板共用底部弹层:名称 + 检查项逐条编辑(≤40 字,可删可加)。
- **新建清单表单**:仅「名称」+「模板」两项 —— **场景选择器已移除**(`scene` 改为沿用所选模板自带的场景,无模板则为空;数据库字段保留,历史数据照常展示)。
- **模板选择**:点击模板行从**右侧滑出** `78vw` 面板(antd-mobile `Popup position="right"`),首项固定「不使用模板」,其后为字母序模板列表(预置带标记 + 项数),选中项主色高亮。
### 11.4 宏观 tab 顺序与溢出提示
- Tab 顺序改为 **报警 → 新闻 → 油价 → 经济数据 → 策略研究 → 经济文章**,默认选中 **新闻**;`/macro` 由路由直接 `Navigate` 到 `/macro/news`(`App.jsx`),`activeFromPath` 兜底同样返回 `news`。
- `SwipeTabs` 增加溢出提示层:测量 `.adm-tabs-tab-list` 的 `scrollLeft + clientWidth < scrollWidth`,为真时在顶部右侧渲染 `.tab-more-hint`(白色渐变 + 主色圆底箭头「›」,1.5s 轻微摆动动画,点击向右翻页 60% 视宽),滚到末尾自动消失。
- 切换 tab 时把激活项平滑滚到可视区中央,避免选中项藏在屏幕外。
- 为什么不用框架自带的:antd-mobile Tabs 自带 `.adm-tabs-header-mask-right` 只有 30px 纯渐变、无箭头、无交互,用户难以判断"右侧还有内容",故叠加此提示层。
### 11.5 待确认项:日历与打卡合并
方案已写入《界面设计.html》2.12 ②节(含主视图、未来日期计划态、卡片「⋯」操作面板三屏)。要点:
| 项 | 方案 |
| --- | --- |
| 结构 | SwipeTabs 由 3 个减为 2 个:`日历(含打卡)` / `清单` |
| 日历 | 月历 + 月份切换 + 回到今天;**去掉「昨天/今天/明天」分段**;日期格 38 → 31px |
| 打卡 | 沿用现有卡片样式,紧凑化:内边距 12→8px、环形 58→42px(环宽 6→5)、间距 10→7px |
| 操作 | 撤销 / 编辑 / 归档收进「⋯」底部面板(原为卡片底部三个文字链接) |
| 分组 | **每天 → 每周 → 每月**,组标题左侧主色竖条;**去掉「每年」分组** |
| 联动 | 点日期即切换当日数据;未来日期只读,按钮灰显「计划」 |
确认后再落地:`CheckinPage` 收敛为 2 个 tab、`CalendarView` 与 `HabitView` 合成新视图组件、`HabitView` 的循环/编辑逻辑抽出复用、样式新增 `.mv-*` 段。
+49 -6
View File
@@ -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
View File
@@ -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 />} />
+2
View File
@@ -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' }),
} }
+12
View File
@@ -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>
)
}
+65 -2
View File
@@ -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}>
@@ -24,4 +87,4 @@ export default function SwipeTabs({ tabs, active, onChange }) {
</div> </div>
</div> </div>
) )
} }
+16 -14
View File
@@ -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 -2
View File
@@ -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"
+3 -2
View File
@@ -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>
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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; }