v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标
用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):
1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
**每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
· 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
· 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
· Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
→ 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。
顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。
验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
越权/不存在均 404、签发短语后 ensure() 不再报错。
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* 任务「完成」复选框(v24 第 25 批 用户第 6 条)—— 全站唯一画法。
|
||||
*
|
||||
* 用户原话:「任务相关的模块,所有的任务,前面都带一个复选框,在任何地方,
|
||||
* 点这个复选框,这个任务就完成了。」
|
||||
*
|
||||
* 所以它出现在**每一个**列出任务的地方:任务看板、首页「手头在做」、任务详情、
|
||||
* 子任务列表。抄四份必然走样(改一处忘三处),所以抽成这一个片段。
|
||||
*
|
||||
* 用法(在模板里先准备好变量再 include):
|
||||
* $tkId 任务的 id(必填)
|
||||
* $tkStatus 当前状态('todo' / 'doing' / 'done')
|
||||
* $tkBack 改完之后回哪儿:'tasks'(默认)或 'show'(详情页里点的时候)
|
||||
* $tkLabel 可选,无障碍用的说明(默认「标记完成」)
|
||||
* $tkStatic 可选,true = 只读账号 → 渲染成**不可点**的静态方块(不是按钮)
|
||||
*
|
||||
* ⚠️ 三件必须做对的事:
|
||||
* ① **它是 button 不是 input[type=checkbox]** ——
|
||||
* 它要发一个带 CSRF 的表单(或者被 JS 接管),
|
||||
* `input` 的 `checked` 是浏览器自己管的,会被"点了没提交成功但看着已勾上"骗到。
|
||||
* 用 button + `aria-pressed` 表达状态,页面上的"勾没勾"始终由**服务端状态**说了算。
|
||||
* ② 表单**全页只渲染一份**(`#taskDoneForm`,见下面的说明):几十张卡就是几十份 form,
|
||||
* 那是名册页被密码管理器拖垮的同一个坑(2369 个 form 的实测记录在 people/index.php)。
|
||||
* 所以卡片上只放 `data-tk-done="<id>"`,真正的表单在页面末尾。
|
||||
* ③ **不写 `checked` 属性、不写 `onsubmit`**:状态由服务端渲染的 class 决定,
|
||||
* 点击由 app.js 统一接管(有 JS → AJAX 就地改;无 JS → 表单提交回 302)。
|
||||
*/
|
||||
$tkId = (int)($tkId ?? 0);
|
||||
$tkStatus = (string)($tkStatus ?? 'todo');
|
||||
$tkBack = (string)($tkBack ?? 'tasks');
|
||||
$tkLabel = (string)($tkLabel ?? '标记完成');
|
||||
$tkStatic = !empty($tkStatic);
|
||||
$tkIsDone = $tkStatus === 'done';
|
||||
if ($tkId <= 0) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<?php if ($tkStatic): ?>
|
||||
<span class="tk-check is-static<?= $tkIsDone ? ' is-done' : '' ?>"
|
||||
aria-hidden="true" title="<?= e(\App\Task::statusWord($tkStatus)) ?>"></span>
|
||||
<?php else: ?>
|
||||
<button type="button" class="tk-check<?= $tkIsDone ? ' is-done' : '' ?>"
|
||||
data-tk-done="<?= $tkId ?>"
|
||||
data-tk-status="<?= e($tkStatus) ?>"
|
||||
data-tk-back="<?= e($tkBack) ?>"
|
||||
aria-pressed="<?= $tkIsDone ? 'true' : 'false' ?>"
|
||||
title="<?= $tkIsDone ? '已完成(再点一下取消)' : '点一下标记完成' ?>">
|
||||
<span class="visually-hidden"><?= e($tkLabel) ?>:<?= e(\App\Task::statusWord($tkStatus)) ?></span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
+186
-18
@@ -1,22 +1,22 @@
|
||||
<?php
|
||||
/**
|
||||
* 微信读书导入(v24 第 24 批 用户第 5 条)。
|
||||
* 微信读书导入(v24 第 24 批 用户第 5 条;**第 25 批 用户第 8 条加了同步与统计**)。
|
||||
*
|
||||
* 两种导入方式,**这里管的是"官方 API Key"那一种**:
|
||||
* ① 手动粘贴 —— 见 `books/notes_import`(早已有,App 里「导出笔记」复制出来即可);
|
||||
* ② 官方授权 —— 本页:填一次微信读书开放平台的 API Key(`wrk-` 开头),
|
||||
* 之后系统就能直接读你的书架笔记,选中要导的书一键导入。
|
||||
* 用户第 8 条原话:「微信读书导入,需要能够做到定期完成,自动同步。
|
||||
* 现在可以导入了,不过封面图看不到,另外也缺少一些对微信读书的
|
||||
* 统计功能,需要加强。」
|
||||
*
|
||||
* 用户原话:「你看一下能不能导入微信的读书笔记,就是每一个人设置一下,比如说扫一下
|
||||
* 微信的这个码呀,或者什么之类的授权一下」→ 后续明确「用微信读书 api key」。
|
||||
* ⚠️ 官方在 2026 年开了开放平台,走**扫码登录后一键生成 API Key**(`wrk-` 前缀),
|
||||
* 不再需要早期那种"复制 cookie"的野路子。Key 的获取页就在本页给出。
|
||||
* ⇒ 这一页现在四块:
|
||||
* ① 授权(填 API Key)
|
||||
* ② **同步**(自动开关 + 立即同步 + 上次/下次时间)—— 第 8 条新增
|
||||
* ③ 拉取预览(勾选要导的书;**每本带封面图**)—— 封面是第 8 条修的
|
||||
* ④ **统计**(阅读时长 / 天数 / 偏好分类 / 读得最多的书)—— 第 8 条新增
|
||||
* ⑤ 另一条路:手动粘贴
|
||||
*
|
||||
* ⚠️ Key 是**账号凭据**(官方原话:等价于账号凭证,泄露等于账号泄露):
|
||||
* 所以界面上**只回显掩码**(`wrk-********abcd`),完整值只在提交那一刻存在。
|
||||
*
|
||||
* @param array|null $preview 「拉取预览」的结果(每本书 + 前几条笔记)
|
||||
* @param array|null $preview 「拉取预览」的结果(每本书 + 前几条笔记 + 封面)
|
||||
* @param int $nbCount 预览时一共发现多少本有笔记的书
|
||||
* @param array $sync 本次自动同步的结果(ran/books/covers/notes/error)
|
||||
* @param array $stats 微信读书的阅读统计(WeRead::readStats)
|
||||
*/
|
||||
$preview = $preview ?? null;
|
||||
$nbCount = (int)($nbCount ?? 0);
|
||||
@@ -24,6 +24,25 @@ $me = (int)\App\Auth::personId();
|
||||
$hasKey = \App\WeRead::hasKey($me);
|
||||
$masked = \App\WeRead::maskedKey($me);
|
||||
$canWrite = \App\Auth::canWrite();
|
||||
$sync = (array)($sync ?? []);
|
||||
$stats = (array)($stats ?? []);
|
||||
$autoOn = \App\WeRead::autoSyncOn($me);
|
||||
|
||||
/** 秒 → 「X 小时 Y 分钟」(官方所有时长字段都是秒,这里统一转人话) */
|
||||
$durWord = static function (int $sec): string {
|
||||
if ($sec <= 0) {
|
||||
return '—';
|
||||
}
|
||||
$h = intdiv($sec, 3600);
|
||||
$m = intdiv($sec % 3600, 60);
|
||||
if ($h > 0) {
|
||||
return $h . ' 小时' . ($m > 0 ? ' ' . $m . ' 分钟' : '');
|
||||
}
|
||||
if ($m > 0) {
|
||||
return $m . ' 分钟';
|
||||
}
|
||||
return $sec . ' 秒';
|
||||
};
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1>微信读书导入</h1>
|
||||
@@ -77,7 +96,150 @@ $canWrite = \App\Auth::canWrite();
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* ==================== ② 拉取预览(先看再导)====================
|
||||
<?php /* ==================== ② 同步(v24 第 25 批 用户第 8 条)====================
|
||||
用户原话:「需要能够做到定期完成,自动同步。」
|
||||
|
||||
⚠️ 这里必须**把机制说清楚**,否则用户会去找"定时任务在哪配":
|
||||
· 家书没有挂系统 cron(见 WeRead::maybeSync 的注释:家用系统可能几周没人访问,
|
||||
挂 cron 是在没人用的时候白烧请求);
|
||||
· 同步发生在**打开这一页的时候**(超过 6 小时才真的联网,否则只读时间戳)。
|
||||
所以这一块直接把「上次 / 下次」两个时间摆出来 —— 他能对上"我刚打开,它就该是最新的"。 */ ?>
|
||||
<?php if ($canWrite && $hasKey): ?>
|
||||
<div class="card card-pad">
|
||||
<h2 class="cinema-h2">
|
||||
自动同步
|
||||
<?php if ($autoOn): ?>
|
||||
<span class="node-badge" style="background:#EAF4EA;color:#3F7D5A;border-color:#C6DFC6">已开启</span>
|
||||
<?php else: ?>
|
||||
<span class="node-badge">已关闭</span>
|
||||
<?php endif; ?>
|
||||
</h2>
|
||||
<p class="small muted" style="margin:0 0 10px">
|
||||
开启后,<b>每次打开这一页都会顺手同步一次</b>(间隔约 6 小时,不会每次都去打接口)。
|
||||
没有挂服务器定时任务 —— 你不在的时候同步不同步没人看得出来,白烧请求没有意义。
|
||||
</p>
|
||||
<div class="wr-sync">
|
||||
<span class="wr-sync-item">上次同步:<b><?= e((string)(\App\WeRead::lastSync($me) ?: '还没同步过')) ?></b></span>
|
||||
<span class="wr-sync-item">下次自动同步:<b><?= $autoOn ? e((string)\App\WeRead::nextSync($me)) : '(已关闭)' ?></b></span>
|
||||
</div>
|
||||
<?php if (!empty($sync['ran'])): ?>
|
||||
<p class="tip" style="margin:10px 0 0">
|
||||
<?php if (!empty($sync['error'])): ?>
|
||||
刚才那次自动同步没成功:<?= e((string)$sync['error']) ?>
|
||||
(不影响页面,下次打开再试)
|
||||
<?php else: ?>
|
||||
刚才顺手同步了一次:新增 <?= (int)$sync['books'] ?> 本书、
|
||||
<?= (int)$sync['covers'] ?> 张封面、<?= (int)$sync['notes'] ?> 条笔记。
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<div class="btn-row" style="margin-top:12px">
|
||||
<form method="post" action="<?= e(url('books/weread')) ?>" class="res-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="act" value="sync_now">
|
||||
<button class="btn btn-sm" type="submit">立即同步</button>
|
||||
</form>
|
||||
<form method="post" action="<?= e(url('books/weread')) ?>" class="res-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="act" value="auto_toggle">
|
||||
<button class="btn btn-ghost btn-sm" type="submit">
|
||||
<?= $autoOn ? '关闭自动同步' : '开启自动同步' ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ==================== ③ 阅读统计(用户第 8 条)====================
|
||||
官方 `/readdata/detail` 给的东西比本项目原来那一套丰富得多,这里先摆最常看的四样:
|
||||
总时长 / 有效阅读天数 / 偏好分类 / 读得最多的书。 */ ?>
|
||||
<?php if ($canWrite && $hasKey && !empty($stats['ok'])): ?>
|
||||
<?php $sd = (array)($stats['data'] ?? []); ?>
|
||||
<div class="card card-pad">
|
||||
<h2 class="cinema-h2">
|
||||
我的阅读统计
|
||||
<span class="hint">来自微信读书官方接口(累计口径)</span>
|
||||
</h2>
|
||||
<div class="wr-stats">
|
||||
<div class="wr-stat">
|
||||
<b><?= e($durWord((int)($sd['seconds'] ?? 0))) ?></b>
|
||||
<span>累计阅读时长</span>
|
||||
</div>
|
||||
<div class="wr-stat">
|
||||
<b><?= (int)($sd['days'] ?? 0) ?> 天</b>
|
||||
<span>有效阅读天数</span>
|
||||
</div>
|
||||
<?php if ((int)($sd['avgSec'] ?? 0) > 0): ?>
|
||||
<div class="wr-stat">
|
||||
<b><?= e($durWord((int)$sd['avgSec'])) ?></b>
|
||||
<span>日均(按自然日)</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php foreach ((array)($sd['stat'] ?? []) as $st): ?>
|
||||
<?php if (trim((string)($st['name'] ?? '')) === '') { continue; } ?>
|
||||
<div class="wr-stat">
|
||||
<b><?= e((string)$st['count']) ?></b>
|
||||
<span><?= e((string)$st['name']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($sd['topBooks'])): ?>
|
||||
<h3 class="wr-sub">读得最多的书</h3>
|
||||
<ul class="wr-top">
|
||||
<?php foreach (array_slice((array)$sd['topBooks'], 0, 6) as $tb): ?>
|
||||
<li class="wr-top-item">
|
||||
<?php if ((string)($tb['cover'] ?? '') !== ''): ?>
|
||||
<img class="wr-top-cv" src="<?= e((string)$tb['cover']) ?>" alt="" loading="lazy">
|
||||
<?php else: ?>
|
||||
<span class="wr-top-cv wr-top-ph">📖</span>
|
||||
<?php endif; ?>
|
||||
<span class="wr-top-b">
|
||||
<span class="wr-top-t"><?= e((string)($tb['title'] ?? '')) ?></span>
|
||||
<?php if ((string)($tb['author'] ?? '') !== ''): ?>
|
||||
<span class="small muted"><?= e((string)$tb['author']) ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<span class="wr-top-time"><?= e($durWord((int)($tb['seconds'] ?? 0))) ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($sd['categories'])): ?>
|
||||
<h3 class="wr-sub">
|
||||
偏好分类
|
||||
<?php if ((string)($sd['preferWord'] ?? '') !== ''): ?>
|
||||
<span class="small muted"><?= e((string)$sd['preferWord']) ?></span>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
<div class="wr-cats">
|
||||
<?php foreach (array_slice((array)$sd['categories'], 0, 8) as $cc): ?>
|
||||
<?php $pct = (int)($cc['val'] ?? 0); ?>
|
||||
<div class="wr-cat">
|
||||
<span class="wr-cat-n"><?= e((string)($cc['name'] ?? '')) ?></span>
|
||||
<span class="wr-cat-bar"><i style="width: <?= max(4, min(100, $pct)) ?>%"></i></span>
|
||||
<span class="wr-cat-v small muted">
|
||||
<?= (int)($cc['books'] ?? 0) ?> 本 · <?= e($durWord((int)($cc['seconds'] ?? 0))) ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($sd['authors'])): ?>
|
||||
<h3 class="wr-sub">偏好作者</h3>
|
||||
<p class="small muted" style="margin:0">
|
||||
<?php $aw = []; foreach ((array)$sd['authors'] as $aa) {
|
||||
$aw[] = e((string)$aa['name']) . '(' . (int)$aa['books'] . ' 本'
|
||||
. ((string)$aa['timeWord'] !== '' ? ' · ' . e((string)$aa['timeWord']) : '') . ')';
|
||||
} echo implode(' · ', array_slice($aw, 0, 6)); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ==================== ④ 拉取预览(先看再导)====================
|
||||
⚠️ 必须有这一步:直接"一键全导"会把别人(或自己多年前)的书一次性灌进书架,
|
||||
想撤回只能一本本删。先给个清单、让用户自己勾,是最省事也最不容易后悔的做法。 */ ?>
|
||||
<?php if ($canWrite && $hasKey): ?>
|
||||
@@ -90,7 +252,7 @@ $canWrite = \App\Auth::canWrite();
|
||||
<button class="btn btn-sm" type="submit">拉取我的笔记清单</button>
|
||||
</div>
|
||||
<p class="tip" style="margin-top:6px">
|
||||
会去微信读书取一次数据(你有笔记的书 + 每本书的划线/想法),可能要十几秒。
|
||||
会去微信读书取一次数据(你有笔记的书 + 每本书的封面/划线/想法),可能要十几秒。
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
@@ -113,6 +275,13 @@ $canWrite = \App\Auth::canWrite();
|
||||
<label class="wr-book">
|
||||
<input type="checkbox" name="pick[]" value="<?= e((string)$p['bookId']) ?>"
|
||||
<?= (int)$p['count'] > 0 ? 'checked' : '' ?>>
|
||||
<?php /* 用户第 8 条:「封面图看不到」—— 预览里也把封面放出来,
|
||||
选书时能一眼认出是哪一本(原来只有一行书名)。 */ ?>
|
||||
<?php if ((string)($p['cover'] ?? '') !== ''): ?>
|
||||
<img class="wr-cv" src="<?= e((string)$p['cover']) ?>" alt="" loading="lazy">
|
||||
<?php else: ?>
|
||||
<span class="wr-cv wr-cv-ph">📖</span>
|
||||
<?php endif; ?>
|
||||
<span class="wr-main">
|
||||
<span class="wr-title"><?= e((string)$p['title']) ?></span>
|
||||
<?php if ((string)$p['author'] !== ''): ?>
|
||||
@@ -150,11 +319,10 @@ $canWrite = \App\Auth::canWrite();
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ==================== ③ 另一条路:手动粘贴 ====================
|
||||
<?php /* ==================== ⑤ 另一条路:手动粘贴 ====================
|
||||
⚠️ 两种方式都留着,不是重复:
|
||||
· API Key 走"全自动",但要求用户愿意把 Key 交出来;
|
||||
· 粘贴走"零授权",App 里「导出笔记」复制一段文字即可。
|
||||
用户可能两个都用(比如只想导某一本书的几段)。 */ ?>
|
||||
· 粘贴走"零授权",App 里「导出笔记」复制一段文字即可。 */ ?>
|
||||
<div class="card card-pad">
|
||||
<h2 class="cinema-h2">或者:手动粘贴笔记</h2>
|
||||
<p class="small muted" style="margin:0 0 10px">
|
||||
|
||||
+16
-8
@@ -77,17 +77,25 @@ if ($curKind !== '' && !in_array($curKind, $kinds, true)) {
|
||||
|
||||
<div class="field">
|
||||
<label>与谁相关</label>
|
||||
<?php /* 第 8 条的同一诉求:人多时不要下拉里瞪眼找。换成弹窗 + 首字母联想;
|
||||
保留「不指定」这一项(原下拉的第一项就是它,不能丢)。 */ ?>
|
||||
<?php /* 用户第 2 条:「每个纪念日现在只能关联一个人,应该可以关联多个人。
|
||||
和选择观影人一样,多选模式。」
|
||||
→ 换 `_person_picker.php` 的 **multi** 模式(与观影选人同一套:
|
||||
弹窗 + 拼音首字母联想 + 暂存区 + 确定)。
|
||||
⚠️ 字段名从 `person_id`(单值)改成 **`person_ids[]`**(多值)——
|
||||
`[]` 不是可有可无的记号:不写它,PHP 只会收到最后一个值,
|
||||
选了 3 个人只存上 1 个,而且**不报任何错**。
|
||||
⚠️ 已选的人:优先用新表的 person_ids;老记录回落到 person_id
|
||||
(升级过渡期的兜底,见 Anniversary::split)。 */ ?>
|
||||
<?php
|
||||
$ppName = 'person_id';
|
||||
$ppName = 'person_ids[]';
|
||||
$ppPeople = $people;
|
||||
$ppSelected = [(int)$v('person_id', 0)];
|
||||
$ppMode = 'single';
|
||||
$ppOpenBtn = '选择人';
|
||||
$ppSelected = (array)($e0['person_ids'] ?? []);
|
||||
if (!$ppSelected && (int)($e0['person_id'] ?? 0) > 0) {
|
||||
$ppSelected = [(int)$e0['person_id']];
|
||||
}
|
||||
$ppMode = 'multi';
|
||||
$ppOpenBtn = '+ 关联人';
|
||||
$ppTitle = '这个纪念日与谁相关';
|
||||
$ppEmptyText = '不指定';
|
||||
$ppAllowEmpty = true;
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
</div>
|
||||
|
||||
+55
-13
@@ -1,21 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* 纪念日(v24 用户第 13 条;2026-10-03 第 9 条重排)
|
||||
* 纪念日(v24 用户第 13 条;2026-10-03 第 9 条重排;**2026-10-07 第 2 条加「关联人」列**)
|
||||
*
|
||||
* 第 9 条原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日,
|
||||
* 要分开,手动纪念日在上面。在新建纪念日和右侧,也增加设置按钮,
|
||||
* 可以设置纪念日的分类。」
|
||||
*
|
||||
* 所以这一页三块,顺序按用户要求:
|
||||
* ① 接下来的日子(未来 60 天,两类混在一起 —— 那块本来就是"该过什么日子了")
|
||||
* ② **手动添加的**纪念日(在上)
|
||||
* 第 2 条原话:「纪念日模块,每个纪念日现在只能关联一个人,应该可以关联多个人。
|
||||
* 和选择观影人一样,多选模式。自己添加的纪念日,右侧按钮,和其他模块的按钮
|
||||
* 样式保持一致,修改用蓝色,删除用黄色。再加一列,关联人。列表中,
|
||||
* 人的前面,都加头像。」
|
||||
*
|
||||
* ⇒ 三块(顺序按用户第 9 条要求):
|
||||
* ① 接下来的日子(未来 60 天)
|
||||
* ② **手动添加的**纪念日(在上)—— 可改可删;右侧按钮:改=蓝(.btn)、删=黄(.btn-warn)
|
||||
* ③ 来自名册生日的(在下,实时派生、不能改删)
|
||||
*
|
||||
* @param array $custom 用户自建的
|
||||
* @param array $derived 从名册生日派生的
|
||||
* @param array $custom 用户自建的(行上带 person_ids)
|
||||
* @param array $derived 从名册生日派生的(行上也有 person_ids)
|
||||
* @param array $upcoming 接下来 60 天的
|
||||
* @param array $kinds 分类清单(设置里维护)
|
||||
* @param array $kindCounts 每个分类用了几条
|
||||
* @param array $people 名册(把 person id 换成头像 + 称呼)
|
||||
*/
|
||||
$canWrite = \App\Auth::canWrite();
|
||||
$kindBadge = static function (array $r): string {
|
||||
@@ -33,8 +38,39 @@ $srcWord = static function (array $r): string {
|
||||
return \App\Anniversary::repeatWord((string)($r['repeat_type'] ?? 'solar'));
|
||||
};
|
||||
|
||||
/* 关联人(用户第 2 条:「再加一列,关联人。列表中,人的前面,都加头像。」)
|
||||
—— 用全站统一的 person_chip()(它自带头像 + 我对他/她的称呼 + 本人底色)。
|
||||
⚠️ chips 一律**不传 href**:这一格在一张表格里,`<a>` 套 `<a>` 是非法 HTML,
|
||||
浏览器会把外层结构切开(person_chip 的注释里记过这个坑)。
|
||||
⚠️ $peopleMap 由路由层传进来(一次查全,避免逐行查库);缺失时退化成"有几个显示几个"。 */
|
||||
$peopleMap = $peopleMap ?? [];
|
||||
$peopleCell = static function (array $r) use ($peopleMap): string {
|
||||
$ids = (array)($r['person_ids'] ?? []);
|
||||
if (!$ids) {
|
||||
return '<span class="small muted">—</span>';
|
||||
}
|
||||
$out = '';
|
||||
$n = 0;
|
||||
foreach ($ids as $pid) {
|
||||
$pid = (int)$pid;
|
||||
$p = $peopleMap[$pid] ?? null;
|
||||
if ($p === null) {
|
||||
continue;
|
||||
}
|
||||
// 一个人时正常画;多人时最多画 4 个 + 「+N」,免得一行被撑爆
|
||||
if ($n >= 4) {
|
||||
$rest = count($ids) - 4;
|
||||
$out .= '<span class="node-badge" title="还有 ' . $rest . ' 人">+' . $rest . '</span>';
|
||||
break;
|
||||
}
|
||||
$out .= person_chip($p);
|
||||
$n++;
|
||||
}
|
||||
return $out === '' ? '<span class="small muted">—</span>' : $out;
|
||||
};
|
||||
|
||||
/* 画一张纪念日表。两块(手动 / 派生)共用同一个渲染 —— 复制一遍迟早改漏一处。 */
|
||||
$renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge, $srcWord): void {
|
||||
$renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge, $srcWord, $peopleCell): void {
|
||||
if (!$rows) {
|
||||
return;
|
||||
}
|
||||
@@ -42,7 +78,7 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><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>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r): ?>
|
||||
@@ -52,16 +88,22 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
|
||||
<?= e((string)$r['title']) ?>
|
||||
<?php if (!empty($r['is_leap'])): ?><span class="small muted">(闰月)</span><?php endif; ?>
|
||||
</td>
|
||||
<td class="ann-people"><?= $peopleCell($r) ?></td>
|
||||
<td><?= $kindBadge($r) ?></td>
|
||||
<td class="small muted"><?= e($srcWord($r)) ?></td>
|
||||
<td class="nowrap">
|
||||
<td class="nowrap ann-acts">
|
||||
<?php if ($editable && $canWrite): ?>
|
||||
<a class="link-quiet" href="<?= e(url('days/edit', ['id' => (int)$r['id']])) ?>">改</a>
|
||||
<?php /* 用户第 2 条:「右侧按钮,和其他模块的按钮样式保持一致,
|
||||
修改用蓝色,删除用黄色。」
|
||||
→ 改 = `.btn`(主按钮的**蓝**),删 = `.btn-warn`(**黄**实心)。
|
||||
⚠️ 原来用的是 `.link-quiet`(纯文字链接)+ `.btn-ghost`(白底描边),
|
||||
两者都"看不出来是什么动作",与观影/旅程行上那套不一致。 */ ?>
|
||||
<a class="btn btn-sm" href="<?= e(url('days/edit', ['id' => (int)$r['id']])) ?>">修改</a>
|
||||
<form method="post" action="<?= e(url('days/destroy')) ?>" class="res-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
|
||||
<button class="btn btn-ghost btn-sm" type="submit"
|
||||
onclick="return confirm('删掉这条纪念日?')">删</button>
|
||||
<button class="btn btn-warn btn-sm" type="submit"
|
||||
onclick="return confirm('删掉这条纪念日?')">删除</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<span class="small muted">来自名册</span>
|
||||
|
||||
+104
-4
@@ -132,9 +132,30 @@ $todayMarks = $todayMarks ?? ['tags' => [], 'line' => ''];
|
||||
<a class="hbs" href="<?= e(url('calendar')) ?>">
|
||||
<b><?= count($todayEvents ?? []) ?></b> 条日程(今天)
|
||||
</a>
|
||||
<a class="hbs" href="<?= e(url('tasks')) ?>">
|
||||
<b><?= (int)($stat7['todos'] ?? 0) ?></b> 件待办
|
||||
<?php /* 🔴🔴 v24 第 25 批 用户第 3 条修:「为什么显示 0 条待办呢?
|
||||
任务看板明明有一个进行中的任务啊。」
|
||||
|
||||
根因:这句话原来读的是 `stat7['todos']` = `count(Task::byStatus(...,'todo'))`
|
||||
—— 只数**待办**。而右边那块「手头在做」列的是 **doing(+ 已逾期的待办)**,
|
||||
用户刚在看板上拖成「进行中」的那件任务于是**不计入**,数字就显示 0。
|
||||
两个口径不同源,数字和列表当场对不上。
|
||||
|
||||
⇒ 现在这个数字与右侧列表**同源**:`doing + 已逾期的 todo`
|
||||
(也就是 `count($tasks)`,与 index.php 传给本页的那个数组完全一致)。
|
||||
文案也说清是什么:「件在做」,而不是含糊的「件待办」。
|
||||
|
||||
⚠️ 「已经动手」与「排队等着」是两件事(用户第 6 条要求说清区别):
|
||||
首页要回答的是"我手头在做什么",所以主数字用「在做」的口径;
|
||||
排队的那些(todo)另给一个小字,不必让人点进任务页才知道还有多少。 */ ?>
|
||||
<a class="hbs" href="<?= e(url('tasks')) ?>"
|
||||
title="手头在做(进行中 + 已逾期的待办)">
|
||||
<b><?= count($tasks ?? []) ?></b> 件在做
|
||||
</a>
|
||||
<?php if ((int)($stat7['todos'] ?? 0) > 0): ?>
|
||||
<a class="hbs" href="<?= e(url('tasks')) ?>" title="记下来了、还没动手的那些">
|
||||
<b><?= (int)$stat7['todos'] ?></b> 件待办
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ((int)($stat7['overdue'] ?? 0) > 0): ?>
|
||||
<a class="hbs is-late" href="<?= e(url('tasks')) ?>">
|
||||
<b><?= (int)$stat7['overdue'] ?></b> 件逾期
|
||||
@@ -257,13 +278,22 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
|
||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('tasks')) ?>">任务看板</a>
|
||||
</div>
|
||||
<div class="hd-pane-body">
|
||||
<?php /* 用户第 6 条:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框,
|
||||
这个任务就完成了。」→ 首页这一块也算"任何地方",
|
||||
所以每行前面也有复选框,点了就地完成(AJAX,不跳页)。
|
||||
表单与 JS 在页面末尾各一份(见 home.php 的 taskDoneForm 段)。 */ ?>
|
||||
<?php if (!$tasks): ?>
|
||||
<p class="empty-hint">没有进行中的任务。</p>
|
||||
<?php else: ?>
|
||||
<ul class="dash-list tk-list">
|
||||
<?php foreach ($tasks as $t): ?>
|
||||
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
|
||||
<li class="dash-row tk-row">
|
||||
<li class="dash-row tk-row" data-task-row="<?= (int)$t['id'] ?>">
|
||||
<?php
|
||||
$tkId = (int)$t['id']; $tkStatus = (string)($t['status'] ?? 'todo');
|
||||
$tkBack = 'home'; $tkStatic = !\App\Auth::canWrite();
|
||||
require __DIR__ . '/_task_check.php';
|
||||
?>
|
||||
<span class="dash-dot" style="background: <?= e($late ? 'var(--red)' : 'var(--brand)') ?>;"></span>
|
||||
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
|
||||
<?= e((string)$t['title']) ?>
|
||||
@@ -322,4 +352,74 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<?php /* ==================== 首页任务复选框的表单与脚本(用户第 6 条)====================
|
||||
「在任何地方点复选框,这个任务就完成了」—— 首页也算"任何地方"。
|
||||
与任务看板同一套做法:**表单全页一份**,行上只放 data-tk-done。
|
||||
⚠️ 改完**不摘掉这一行**:用户点完正想看自己刚完成什么,
|
||||
立刻消失会让人怀疑"是不是点到删除了"。刷新时由服务端决定它还在不在。 */ ?>
|
||||
<?php if (!empty($tasks) && \App\Auth::canWrite()): ?>
|
||||
<form method="post" action="<?= e(url('tasks/status')) ?>" id="taskDoneForm" class="hidden-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" id="doneId">
|
||||
<input type="hidden" name="status" id="doneStatus">
|
||||
<input type="hidden" name="back" id="doneBack" value="home">
|
||||
</form>
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('taskDoneForm');
|
||||
if (!form) { return; }
|
||||
var staticWord = {todo: '待办', doing: '进行中', done: '已完成'};
|
||||
|
||||
function say(msg, ok) {
|
||||
var host = document.querySelector('.flash');
|
||||
if (!host) {
|
||||
host = document.createElement('div');
|
||||
host.className = 'flash';
|
||||
var main = document.querySelector('main') || document.body;
|
||||
main.insertBefore(host, main.firstChild);
|
||||
}
|
||||
var el = document.createElement('div');
|
||||
el.className = 'flash-item' + (ok ? '' : ' err');
|
||||
el.setAttribute('role', 'status');
|
||||
el.textContent = msg;
|
||||
host.insertBefore(el, host.firstChild);
|
||||
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 3200);
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.tk-check[data-tk-done]'), function (b) {
|
||||
b.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
var now = b.getAttribute('data-tk-status') || 'todo';
|
||||
var next = (now === 'done') ? 'todo' : 'done';
|
||||
document.getElementById('doneId').value = b.getAttribute('data-tk-done');
|
||||
document.getElementById('doneStatus').value = next;
|
||||
document.getElementById('doneBack').value = b.getAttribute('data-tk-back') || 'home';
|
||||
if (!window.fetch || !window.FormData) { form.submit(); return; }
|
||||
b.disabled = true;
|
||||
fetch(form.getAttribute('action'), {
|
||||
method: 'POST', body: new FormData(form), credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
}).then(function (r) { return r.json().catch(function () { return null; }); })
|
||||
.then(function (d) {
|
||||
b.disabled = false;
|
||||
if (!d || !d.ok) { form.submit(); return; }
|
||||
b.setAttribute('data-tk-status', d.status);
|
||||
b.classList.toggle('is-done', d.status === 'done');
|
||||
b.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false');
|
||||
// 状态徽标(那行「进行中」)跟着换,否则勾上了徽标还写着"进行中"
|
||||
var row = b.closest('[data-task-row]');
|
||||
var badge = row ? row.querySelector('.kb-st') : null;
|
||||
if (badge) {
|
||||
badge.className = 'kb-st is-' + d.status + ' is-static';
|
||||
badge.textContent = staticWord[d.status] || d.word;
|
||||
}
|
||||
say(d.msg || '已更新。', true);
|
||||
}).catch(function () { b.disabled = false; form.submit(); });
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
+100
-48
@@ -109,17 +109,24 @@ $ledMe = (int)($me ?? 0);
|
||||
选中「存款」后,净值仍代表整本账,避免屏幕上只剩一个类别、
|
||||
却被误读成"我家只有存款"。选中的类别是谁,由下面那条高亮条说明。 */ ?>
|
||||
<div class="ck-filter led-kinds" data-ck-filter>
|
||||
<?php /* ⚠️ v24 第 25 批 用户第 7 条:标签改成**多选**(互不排斥)。
|
||||
用户原话:「那几个标签是可以多选的,它们之间不是互斥的,我选中这个标签,
|
||||
就开始统计这个类别,我再点一次,就取消选择,不统计这个类别。」
|
||||
⇒ 所以这里的「全部」不再是一颗"选项",而是**一键清空选择**的复位键。
|
||||
文案写成「全部,点这里复位」比只写「全部」更能说明它不是同类选项。 */ ?>
|
||||
<button type="button" class="ck-chip is-on" data-ck-kind=""
|
||||
data-ck-sum="<?= e($money($summary['net'])) ?>"
|
||||
data-ck-n="<?= (int)$summary['count'] ?>"
|
||||
data-ck-word="整本账">全部(<?= (int)$summary['count'] ?>)</button>
|
||||
data-ck-word="整本账"
|
||||
title="清空选择,看整本账">全部(<?= (int)$summary['count'] ?>)</button>
|
||||
<?php foreach ($summary['by_kind'] as $k => $c): ?>
|
||||
<button type="button"
|
||||
class="ck-chip led-kind-chip<?= \App\Ledger::isDebt((string)$k) ? ' is-debt' : '' ?>"
|
||||
data-ck-kind="<?= e((string)$k) ?>"
|
||||
data-ck-sum="<?= e($money((int)$c)) ?>"
|
||||
data-ck-n="<?= (int)($summary['by_kind_n'][$k] ?? 0) ?>"
|
||||
data-ck-word="<?= e(\App\Ledger::kindWord((string)$k)) ?>">
|
||||
data-ck-word="<?= e(\App\Ledger::kindWord((string)$k)) ?>"
|
||||
title="点一下开始统计「<?= e(\App\Ledger::kindWord((string)$k)) ?>」,再点一下取消">
|
||||
<?= \App\Ledger::kindIcon((string)$k) ?> <?= e(\App\Ledger::kindWord((string)$k)) ?>
|
||||
<b><?= e($money((int)$c)) ?></b>
|
||||
</button>
|
||||
@@ -560,22 +567,33 @@ $ledMe = (int)($me ?? 0);
|
||||
现在点击后没有任何效果,这个需要做处理,点击任何一个标签,
|
||||
则统计这个标签下的所有财务条目的总数,并在上下两个图上,画出来。」
|
||||
|
||||
🔴🔴 v24 第 25 批 用户第 7 条:**改成多选(标签之间不互斥)**。
|
||||
用户原话:「财务的那几个标签,点击之后图片变化的时候,那几个标签是可以多选的,
|
||||
它们之间不是互斥的,我选中这个标签,就开始统计这个类别,
|
||||
我再点一次,就取消选择,不统计这个类别。」
|
||||
|
||||
⇒ 语义从「选中**一个**类别」变成「持有一个**选中的集合**」:
|
||||
· 点一个没选中的 → 加进集合(开始统计这一类)
|
||||
· 点一个已选中的 → 从集合里去掉(不再统计)
|
||||
· 集合为空 = 等同「全部」(这比"一个都不显示"友好:
|
||||
用户取消了最后一个类别时,看到的是回到全貌,而不是一张空图)
|
||||
· 「全部」那颗按钮 = 一键清空集合(不是"再点一下取消它自己")
|
||||
之所以不是"一个都不选 = 什么都不统计":`空集`与`全选`在图上看是同一件事
|
||||
(所有线都画出来),而"点掉最后一个 → 整张图空掉"看着像坏了。
|
||||
|
||||
一处逻辑,四件事同时做(缺一件用户就会觉得"没生效"):
|
||||
① 标签高亮(`.is-on`);
|
||||
② 「总金额」图切到该类别的曲线(`[data-ck-tkind]`,都预渲染好了);
|
||||
③ 多线图里只留该类别的线(`[data-ck-line]`)+ 图例只留对应项;
|
||||
④ 账目表只列该类条目(`[data-ck-row]`)。
|
||||
另外在概览卡里给一条高亮:看的是哪一类、合计多少、几笔,并给「清除筛选」。
|
||||
② 「总金额」图:集合里**只剩一个**类别时切到该类的单线图,
|
||||
否则显示整本账(多选时逐类单线图没有意义 —— 那是"把几条线并起来",
|
||||
而多线图已经在做这件事了);
|
||||
③ 多线图里只留**集合内**类别(`[data-ck-line]`)+ 图例只留对应项;
|
||||
④ 账目表只列集合内类别(`[data-ck-row]`)。
|
||||
另外在概览卡里给一条高亮,并给「清除筛选」。
|
||||
|
||||
⚠️ 两处 `.ck-filter`(概览卡的标签 + 图表卡的 chip)共用本函数 ——
|
||||
它们是**同一个筛选器**的两个入口。如果只给其中一处绑事件,
|
||||
点另一处就会"两处状态不一致"(一处亮着、图上却是另一个类别的结果)。
|
||||
所以:任何一处的点击都会把两处的高亮 + 全部图表 + 表格一起重画。
|
||||
⚠️ 图例项**不**用 hidden 藏:`.ck-lg-item[hidden]{display:none}` 的规则本来就有,
|
||||
但"藏起来"会让图例的长度跳变。折中:藏(保持整齐一致),
|
||||
并在下面的说明行里写清"只在看这一类"。
|
||||
⚠️ 「全部」按钮的 data-ck-sum 用的是**净资产**(net),不是资产合计 ——
|
||||
标签上那句话读起来是"整本账 = 多少钱",净资产才是那个数。 */
|
||||
它们是**同一个筛选器**的两个入口,两处的高亮必须**完全同步**:
|
||||
所以这里不重算"哪些该亮",而是**两处按同一个集合**刷一遍。
|
||||
⚠️ 图例项**不**用 hidden 藏:折中成"藏",并在下面的说明行里写清"只看这几类"。 */
|
||||
var bars = document.querySelectorAll('[data-ck-filter]');
|
||||
if (bars.length) {
|
||||
var focusBar = document.querySelector('[data-ck-focus]');
|
||||
@@ -583,68 +601,97 @@ $ledMe = (int)($me ?? 0);
|
||||
var noteAll = document.querySelector('[data-ck-note-all]');
|
||||
var noteOne = document.querySelector('[data-ck-note-one]');
|
||||
|
||||
function applyKind(kind, sum, n, word) {
|
||||
// ① 两处标签的高亮保持同步(同一个筛选器)
|
||||
/** 当前选中的类别集合(空集 = 全部) */
|
||||
var picked = [];
|
||||
|
||||
/** 读一遍某颗标签上带的说明数据(合计 / 笔数 / 类别名) */
|
||||
function chipInfo(bar, kind) {
|
||||
var c = bar.querySelector('.ck-chip[data-ck-kind="' + kind + '"]');
|
||||
return c ? {
|
||||
sum: c.getAttribute('data-ck-sum') || '',
|
||||
n: c.getAttribute('data-ck-n') || '',
|
||||
word: c.getAttribute('data-ck-word') || '',
|
||||
} : {sum: '', n: '', word: ''};
|
||||
}
|
||||
|
||||
function reapply() {
|
||||
// ① 两处标签的高亮保持同步(按同一个集合)
|
||||
Array.prototype.forEach.call(bars, function (bar) {
|
||||
Array.prototype.forEach.call(bar.querySelectorAll('.ck-chip'), function (c) {
|
||||
c.classList.toggle('is-on', (c.getAttribute('data-ck-kind') || '') === kind);
|
||||
var k = c.getAttribute('data-ck-kind') || '';
|
||||
// 「全部」那颗:集合为空时亮(它就是"没有筛选"这个状态)
|
||||
c.classList.toggle('is-on', k === '' ? picked.length === 0 : picked.indexOf(k) >= 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ② 总金额图:只显示匹配的那一张
|
||||
// ② 总金额图:只在"恰好选了一类"时切到那一类;多选或空集都显示整本账
|
||||
var sole = picked.length === 1 ? picked[0] : '';
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-tkind]'), function (w) {
|
||||
w.hidden = (w.getAttribute('data-ck-tkind') || '') !== kind;
|
||||
w.hidden = (w.getAttribute('data-ck-tkind') || '') !== sole;
|
||||
});
|
||||
// 图上的标题跟着换(不然选着"存款",标题还写"净资产",自相矛盾)
|
||||
var ttl = document.querySelector('[data-ck-total-title]');
|
||||
if (ttl) {
|
||||
ttl.textContent = kind === '' ? '总金额 · 净资产' : (word + ' · 金额变化');
|
||||
var info = sole !== '' ? chipInfo(bars[0], sole) : null;
|
||||
ttl.textContent = sole === '' ? '总金额 · 净资产'
|
||||
: (info && info.word ? info.word : '') + ' · 金额变化';
|
||||
}
|
||||
|
||||
// ③ 多线图:按类别显隐线条(几何不动,只切可见性)
|
||||
// ③ 多线图:按集合显隐线条(几何不动,只切可见性)
|
||||
//
|
||||
// 🔴🔴 v24 第 24 批 用户第 6 条修的真 bug(我上一批引入的):
|
||||
// 这里原来写的是 `g.hidden = ...` —— **对 SVG 元素完全无效**。
|
||||
// `hidden` 是 **HTML 元素**的属性(映射到 `[hidden]{display:none}`);
|
||||
// `<g>` 是 SVG 元素,它身上根本没有 `hidden` 这个 IDL 属性,
|
||||
// 赋值只是往对象上挂了一个**谁都不看的 JS 属性** → 线条一条都没被藏起来。
|
||||
// 用户原话:「选了存款,它本来有五条线,但是这还是显示的所有的线。」
|
||||
// 症状最迷惑的地方:第一张图(用 `w.hidden` 藏 **div**)**真的会换**,
|
||||
// 所以看起来"筛选基本是好的,只是第二张图有点不太对"。
|
||||
// ⇒ 正确的隐藏方式是 **`style.display`**(SVG 认这个)。
|
||||
// 必须同时给 `g` 与 `li`/`tr` 用同一种写法:
|
||||
// · `<g>` 是 SVG → 只能 display
|
||||
// · `.ck-lg-item` / `<tr>` 是 HTML,两样都行;统一用 display 少一个特例。
|
||||
// ⚠️ 反向也要对:`style.display = ''` 是"恢复默认",不是 `'block'` ——
|
||||
// 给 `<g>` 写 display:block 会把 SVG 的显示模型搞乱(不同浏览器表现不一致)。
|
||||
function keep(k) {
|
||||
// 空集 = 全部(都留);否则只留在集合里的
|
||||
return picked.length === 0 || picked.indexOf(k) >= 0;
|
||||
}
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-line]'), function (g) {
|
||||
g.style.display = (kind !== '' && (g.getAttribute('data-ck-line') || '') !== kind)
|
||||
? 'none' : '';
|
||||
g.style.display = keep(g.getAttribute('data-ck-line') || '') ? '' : 'none';
|
||||
});
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.ck-lg-item'), function (li) {
|
||||
li.style.display = (kind !== '' && (li.getAttribute('data-ck-kind') || '') !== kind)
|
||||
? 'none' : '';
|
||||
li.style.display = keep(li.getAttribute('data-ck-kind') || '') ? '' : 'none';
|
||||
});
|
||||
|
||||
// ④ 账目表
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-row]'), function (tr) {
|
||||
tr.style.display = (kind !== '' && (tr.getAttribute('data-ck-row') || '') !== kind)
|
||||
? 'none' : '';
|
||||
tr.style.display = keep(tr.getAttribute('data-ck-row') || '') ? '' : 'none';
|
||||
});
|
||||
|
||||
// ⑤ 概览卡里的高亮条
|
||||
// ⑤ 概览卡里的高亮条 + 图下的说明
|
||||
if (focusBar && focusTxt) {
|
||||
focusBar.hidden = (kind === '');
|
||||
if (kind !== '') {
|
||||
focusTxt.textContent = '正在看「' + word + '」:合计 ' + sum + ' · ' + n + ' 笔';
|
||||
focusBar.hidden = (picked.length === 0);
|
||||
if (picked.length > 0) {
|
||||
var words = [];
|
||||
var sumAll = 0, nAll = 0;
|
||||
picked.forEach(function (k) {
|
||||
var i = chipInfo(bars[0], k);
|
||||
words.push(i.word);
|
||||
// 合计:把每个标签上那串金额里的数字抠出来相加(金额是带千分位的字符串)
|
||||
var digits = String(i.sum).replace(/[^0-9.\-]/g, '');
|
||||
if (digits !== '' && !isNaN(parseFloat(digits))) { sumAll += parseFloat(digits); }
|
||||
nAll += parseInt(i.n || '0', 10) || 0;
|
||||
});
|
||||
// 合计只对"金额可直接相加"的类别准确(负债类在同一本账里是负数),
|
||||
// 所以这句说明里把笔数写在前、合计写在后,别让用户把它当成权威口径。
|
||||
focusTxt.textContent = '正在看 ' + picked.length + ' 类(' + words.join('、') + '):'
|
||||
+ nAll + ' 笔';
|
||||
}
|
||||
}
|
||||
if (noteAll && noteOne) {
|
||||
noteAll.hidden = (kind !== '');
|
||||
noteOne.hidden = (kind === '');
|
||||
if (kind !== '') {
|
||||
noteOne.textContent = '只画了「' + word + '」这一类(' + n + ' 条线),'
|
||||
+ '合计 ' + sum + ';点「清除筛选」看整本账。';
|
||||
noteAll.hidden = (picked.length > 0);
|
||||
noteOne.hidden = (picked.length === 0);
|
||||
if (picked.length > 0) {
|
||||
var ws = [];
|
||||
picked.forEach(function (k) { ws.push(chipInfo(bars[0], k).word); });
|
||||
noteOne.textContent = '只画了「' + ws.join('、') + '」这 ' + picked.length + ' 类;'
|
||||
+ '再点一次标签可以取消这一类,点「清除筛选」看整本账。';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,17 +702,22 @@ $ledMe = (int)($me ?? 0);
|
||||
var chip = t.closest('.ck-filter .ck-chip');
|
||||
if (chip) {
|
||||
e.preventDefault();
|
||||
applyKind(chip.getAttribute('data-ck-kind') || '',
|
||||
chip.getAttribute('data-ck-sum') || '',
|
||||
chip.getAttribute('data-ck-n') || '',
|
||||
chip.getAttribute('data-ck-word') || '');
|
||||
var kind = chip.getAttribute('data-ck-kind') || '';
|
||||
if (kind === '') {
|
||||
picked = []; // 「全部」= 清空集合
|
||||
} else {
|
||||
var i = picked.indexOf(kind);
|
||||
if (i >= 0) { picked.splice(i, 1); } // 再点一次 = 取消这一类
|
||||
else { picked.push(kind); } // 点一次 = 开始统计这一类
|
||||
}
|
||||
reapply();
|
||||
return;
|
||||
}
|
||||
// 清除筛选 = 回到"全部"那一档(不自己拼状态,直接找那颗按钮点它一次)
|
||||
// 清除筛选 = 回到"全部"(不自己拼状态,直接清空集合)
|
||||
if (t.closest('[data-ck-clear]')) {
|
||||
e.preventDefault();
|
||||
var all = document.querySelector('[data-ck-filter] .ck-chip[data-ck-kind=""]');
|
||||
if (all) { all.click(); }
|
||||
picked = [];
|
||||
reapply();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -692,5 +692,142 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
|
||||
else { form.submit(); }
|
||||
});
|
||||
})();
|
||||
|
||||
<?php /* ==================== v24 第 25 批 第 1 条:改分组**不刷新页面** ====================
|
||||
用户原话:「名册模块在列表中,对列表中的某一个人进行分组,也不应该刷新界面,
|
||||
而是应该动态调整页面。」
|
||||
|
||||
原来:`change` 一下 → `requestSubmit()` → 服务端 302 回 people 页 → **整页重载**
|
||||
(搜索框里的字没了、滚动位置回到顶部、正在拖的栏也收起来了)。
|
||||
|
||||
现在:拦下那个提交,改 `fetch` POST;成功后**就地把他挪到目标分组栏里**,
|
||||
并更新两栏的人数与「共 N 人」不变,最后重跑一次筛选(复用 filter.js)。
|
||||
|
||||
⚠️ 四个必须踩准的点(与删除那段的四条同源,但多了一条"挪卡片"):
|
||||
① **卡片真的要在栏之间移动**,不能只改按钮上的文字 ——
|
||||
用户改分组的目的是"看到他在新栏里",只换文字等于没做。
|
||||
② 移动要**插到目标栏末尾**,而不是 append 到 .roster(那会掉到所有栏外面)。
|
||||
③ 目标栏原本是空的(`is-empty` + `hidden`)→ 必须把 `is-empty` 去掉、
|
||||
`hidden` 也要清掉。只去 is-empty 没用:作者样式里 `.group-block` 没写 display,
|
||||
浏览器默认的 `[hidden]{display:none}` 照样生效,那一栏连同刚挪进去的人一起看不见
|
||||
(app.js 的 refreshCounts() 里记过同一个坑)。
|
||||
④ 失败一律**退回原生提交**:网络断/会话过期/权限不足 ——
|
||||
任何一种都不能静默失败("点了没反应"比"刷一下"糟得多)。 */ ?>
|
||||
(function () {
|
||||
var wrap = document.getElementById('rosterMenus');
|
||||
if (!wrap) { return; }
|
||||
var gPanel = document.getElementById('rmGroup');
|
||||
var form = gPanel ? gPanel.querySelector('form.pg-menu') : null;
|
||||
if (!form || !window.fetch || !window.FormData) { return; }
|
||||
|
||||
var totalEl = document.querySelector('[data-roster-total]');
|
||||
var searchInput = document.getElementById('rosterSearch');
|
||||
|
||||
/** 就地把他挪到目标分组栏里 + 两栏人数跟着变 */
|
||||
function moveCard(pid, gid, gname) {
|
||||
var card = document.querySelector('.person[data-person="' + pid + '"]');
|
||||
if (!card) { return false; }
|
||||
var from = card.closest('.group-block');
|
||||
var toSec = document.querySelector('.group-block[data-group="' + gid + '"]');
|
||||
if (!toSec) { return false; } // 目标栏不存在(分组被删了)→ 别乱挪
|
||||
var grid = toSec.querySelector('.people-grid');
|
||||
if (!grid) { return false; }
|
||||
|
||||
// ① 先记下"他在哪一栏"(要减人数的那一栏)
|
||||
var fromGid = from ? from.getAttribute('data-group') : null;
|
||||
|
||||
// ② 挪过去(插到末尾:新归到一组的人在那一组最后,与拖拽的语义一致)
|
||||
grid.appendChild(card);
|
||||
|
||||
// ③ 目标栏:清 is-empty 与 hidden(空栏平时是收起来的)
|
||||
toSec.classList.remove('is-empty');
|
||||
toSec.hidden = false;
|
||||
|
||||
// ④ 两栏的人数都重数一遍(比 +1/-1 稳:筛选着的时候"可见数"≠"总数")
|
||||
[fromGid, String(gid)].forEach(function (g) {
|
||||
if (g === null) { return; }
|
||||
var sec = document.querySelector('.group-block[data-group="' + g + '"]');
|
||||
var nEl = document.querySelector('[data-group-n="' + g + '"]');
|
||||
if (!sec || !nEl) { return; }
|
||||
var n = sec.querySelectorAll('.people-grid .person').length;
|
||||
nEl.textContent = String(n);
|
||||
sec.classList.toggle('is-empty', n === 0);
|
||||
});
|
||||
|
||||
// ⑤ 按钮上显示的文字跟着换(用户第 1 条:那颗按钮**显示当前分组**)
|
||||
var btn = card.querySelector('.person-group-btn');
|
||||
if (btn) {
|
||||
btn.textContent = gname !== '' ? gname : '分组';
|
||||
btn.classList.toggle('is-none', gname === '');
|
||||
btn.setAttribute('data-pg-cur', String(gid));
|
||||
btn.setAttribute('title', '改 ' + (btn.getAttribute('data-pg-name') || '') + ' 的分组');
|
||||
}
|
||||
|
||||
// ⑥ 让 filter.js 重算筛选/空态/分栏显隐(它监听的正是 input 事件)
|
||||
if (searchInput) {
|
||||
try { searchInput.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function say(ok, msg) {
|
||||
var host = document.querySelector('.flash');
|
||||
if (!host) {
|
||||
host = document.createElement('div');
|
||||
host.className = 'flash';
|
||||
var main = document.querySelector('main') || document.body;
|
||||
main.insertBefore(host, main.firstChild);
|
||||
}
|
||||
var el = document.createElement('div');
|
||||
el.className = 'flash-item' + (ok ? '' : ' err');
|
||||
el.setAttribute('role', 'status');
|
||||
el.textContent = msg;
|
||||
host.insertBefore(el, host.firstChild);
|
||||
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 4000);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (ev) {
|
||||
if (form.getAttribute('data-ajax-fallback') === '1') { return; }
|
||||
ev.preventDefault();
|
||||
|
||||
var pid = (form.querySelector('input[name="person_id"]') || {}).value || '';
|
||||
var checked = form.querySelector('input[name="group_id"]:checked');
|
||||
var gid = checked ? (parseInt(checked.value || '0', 10) || 0) : 0;
|
||||
var btn = form.querySelector('button[type="submit"]');
|
||||
if (btn) { btn.disabled = true; }
|
||||
|
||||
fetch(form.getAttribute('action'), {
|
||||
method: 'POST',
|
||||
body: new FormData(form),
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
}).then(function (r) {
|
||||
return r.json().catch(function () { return null; });
|
||||
}).then(function (d) {
|
||||
if (btn) { btn.disabled = false; }
|
||||
if (d && d.ok) {
|
||||
if (wrap) { wrap.hidden = true; }
|
||||
if (gPanel) { gPanel.hidden = true; }
|
||||
moveCard(String(pid), gid, String(d.gname || ''));
|
||||
say(true, d.msg || '分组已改。');
|
||||
return;
|
||||
}
|
||||
if (d && d.msg) {
|
||||
if (wrap) { wrap.hidden = true; }
|
||||
if (gPanel) { gPanel.hidden = true; }
|
||||
say(false, d.msg);
|
||||
return;
|
||||
}
|
||||
form.setAttribute('data-ajax-fallback', '1');
|
||||
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
|
||||
else { form.submit(); }
|
||||
}).catch(function () {
|
||||
if (btn) { btn.disabled = false; }
|
||||
form.setAttribute('data-ajax-fallback', '1');
|
||||
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
|
||||
else { form.submit(); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
+136
-7
@@ -167,6 +167,33 @@ $lastColIdx = count($colList) - 1;
|
||||
<input type="hidden" name="before" id="mvBefore">
|
||||
</form>
|
||||
|
||||
<?php /* v24 第 25 批 用户第 6 条:**每张卡前面一颗「完成」复选框**。
|
||||
用户原话:「任务相关的模块,所有的任务,前面都带一个复选框,在任何地方,
|
||||
点这个复选框,这个任务就完成了。」
|
||||
⚠️ 卡片上只放 `data-tk-done`(见 _task_check.php),真正的表单**全页一份**,
|
||||
放在这下面 —— 与搬卡/改状态那两份同一个思路:
|
||||
几十张卡就是几十份 form,那是名册页被密码管理器拖垮的同一个坑。 */ ?>
|
||||
<?php if ($canWrite): ?>
|
||||
<form method="post" action="<?= e(url('tasks/status')) ?>" id="taskDoneForm" class="hidden-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" id="doneId">
|
||||
<input type="hidden" name="status" id="doneStatus">
|
||||
<input type="hidden" name="back" id="doneBack" value="tasks">
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* 状态到底是什么(用户第 6 条:「待办和进行中,有什么区别?」)——
|
||||
把三个状态的一句话解释直接摆在看板上方,而不是藏在 title 里。
|
||||
他问出这句话,就说明界面上没有一处讲清楚过。 */ ?>
|
||||
<div class="tk-legend">
|
||||
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
|
||||
<span class="tk-lg-item">
|
||||
<span class="kb-st is-<?= e($sk) ?> is-static"><?= e($sv) ?></span>
|
||||
<span class="tk-lg-hint"><?= e(\App\Task::statusHint($sk)) ?></span>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php /* v24 第 14 批 用户第 2 条:**每张卡上都能看到并改状态**。
|
||||
⚠️ 与上面搬卡那份同理:表单全页只有一份,卡片上只放 `data-st-*`,
|
||||
不然几十张卡就是几十份 form —— 名册就是这么被密码管理器拖垮的。 */ ?>
|
||||
@@ -194,9 +221,19 @@ $lastColIdx = count($colList) - 1;
|
||||
?>
|
||||
<div class="kb-card<?= $overdue ? ' is-overdue' : '' ?>" data-id="<?= $tid ?>"
|
||||
draggable="<?= $canWrite ? 'true' : 'false' ?>">
|
||||
<a class="kb-title" href="<?= e(url('tasks/show', ['id' => $tid])) ?>">
|
||||
<?= e((string)$t['title']) ?>
|
||||
</a>
|
||||
<?php /* 用户第 6 条:**任务前面带复选框**,点它就是"完成"。
|
||||
⚠️ 复选框与标题必须包在 `.kb-top` 里**横排** ——
|
||||
卡片是 flex-column,不包的话复选框独占一行、标题被挤下去。 */ ?>
|
||||
<div class="kb-top">
|
||||
<?php
|
||||
$tkId = $tid; $tkStatus = (string)($t['status'] ?? 'todo');
|
||||
$tkBack = 'tasks'; $tkStatic = !$canWrite;
|
||||
require __DIR__ . '/../_task_check.php';
|
||||
?>
|
||||
<a class="kb-title" href="<?= e(url('tasks/show', ['id' => $tid])) ?>">
|
||||
<?= e((string)$t['title']) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php /* 状态徽标:每一张卡都有(用户第 2 条「每个任务都应该有不同的状态」)。
|
||||
点一下推进到下一个状态:待办 → 进行中 → 已完成 → 回到待办
|
||||
(最后一档做成可回头,是"点错了能改回来",完成本身另有详情页的按钮)。
|
||||
@@ -301,19 +338,111 @@ $lastColIdx = count($colList) - 1;
|
||||
|
||||
/* ---- 状态徽标:点一下推进到下一个状态(用户第 2 条)----
|
||||
走的是**全页一份**的 #taskStatusForm(卡片上只有 data-st-*),
|
||||
与搬卡那份表单同一思路 —— 不这么做的话,几十张卡就是几十份 form。 */
|
||||
与搬卡那份表单同一思路 —— 不这么做的话,几十张卡就是几十份 form。
|
||||
⚠️ v24 第 25 批 用户第 6 条:改成 **AJAX 就地改**(原来是整页提交)。
|
||||
理由是"复选框在任何地方点一下就完成"这件事,如果每次都刷整页,
|
||||
在首页点完会跳回首页顶部、滚动位置全丢 —— 那不是"点一下",是"打一次断"。
|
||||
失败仍退回原生提交(见 catch / 无 JSON 分支),所以没有 JS 照样能用。 */
|
||||
var stForm = document.getElementById('taskStatusForm');
|
||||
if (stForm) {
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.kb-st[data-st-id]'), function (b) {
|
||||
b.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.getElementById('stId').value = b.getAttribute('data-st-id');
|
||||
document.getElementById('stStatus').value = b.getAttribute('data-next');
|
||||
stForm.submit();
|
||||
submitStatus(b.getAttribute('data-st-id'), b.getAttribute('data-next'), b);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- 完成复选框(用户第 6 条):点一下 = 完成 ---- */
|
||||
var doneForm = document.getElementById('taskDoneForm');
|
||||
function submitStatus(id, status, btn) {
|
||||
if (!doneForm) { return; }
|
||||
document.getElementById('doneId').value = id;
|
||||
document.getElementById('doneStatus').value = status;
|
||||
document.getElementById('doneBack').value = (btn && btn.getAttribute('data-tk-back')) || 'tasks';
|
||||
// 没有 fetch(极老浏览器)→ 直接原生提交,不假装成功
|
||||
if (!window.fetch || !window.FormData) {
|
||||
doneForm.submit();
|
||||
return;
|
||||
}
|
||||
if (btn) { btn.disabled = true; }
|
||||
fetch(doneForm.getAttribute('action'), {
|
||||
method: 'POST',
|
||||
body: new FormData(doneForm),
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
}).then(function (r) {
|
||||
return r.json().catch(function () { return null; });
|
||||
}).then(function (d) {
|
||||
if (btn) { btn.disabled = false; }
|
||||
if (d && d.ok) {
|
||||
applyStatus(String(d.id), String(d.status), String(d.word || ''));
|
||||
return;
|
||||
}
|
||||
if (d && d.msg) { say(d.msg, false); return; }
|
||||
// 拿不到 JSON(会话过期 / 500)→ 退回原生提交,让服务端说话
|
||||
doneForm.submit();
|
||||
}).catch(function () {
|
||||
if (btn) { btn.disabled = false; }
|
||||
doneForm.submit();
|
||||
});
|
||||
}
|
||||
|
||||
/** 把一张卡的状态**就地**改掉:复选框、状态徽标、卡片样式一起动。
|
||||
* ⚠️ 完成之后看板上还有没有这张卡,交给用户刷新时由服务端决定 ——
|
||||
* 不在这里偷偷摘卡片:任务是"刚做完",正想看一眼自己刚完成什么,
|
||||
* 立刻消失会让人怀疑"是不是点到删除了"。 */
|
||||
function applyStatus(id, status, word) {
|
||||
var card = document.querySelector('.kb-card[data-id="' + id + '"]');
|
||||
if (!card) { return; }
|
||||
var chk = card.querySelector('.tk-check[data-tk-done]');
|
||||
if (chk) {
|
||||
chk.setAttribute('data-tk-status', status);
|
||||
chk.classList.toggle('is-done', status === 'done');
|
||||
chk.setAttribute('aria-pressed', status === 'done' ? 'true' : 'false');
|
||||
}
|
||||
var badge = card.querySelector('.kb-st[data-st-id]');
|
||||
if (badge) {
|
||||
var next = status === 'todo' ? 'doing' : (status === 'doing' ? 'done' : 'todo');
|
||||
badge.className = 'kb-st is-' + status;
|
||||
badge.setAttribute('data-st', status);
|
||||
badge.setAttribute('data-next', next);
|
||||
badge.textContent = word;
|
||||
badge.setAttribute('title', '现在:' + word + ';点一下改成「'
|
||||
+ ({todo: '待办', doing: '进行中', done: '已完成'}[next] || '') + '」');
|
||||
}
|
||||
card.classList.toggle('is-done', status === 'done');
|
||||
say('已标成「' + word + '」。', true);
|
||||
}
|
||||
|
||||
/** 页内提示(用页面自己的 flash 样式,不弹 alert) */
|
||||
function say(msg, ok) {
|
||||
var host = document.querySelector('.flash');
|
||||
if (!host) {
|
||||
host = document.createElement('div');
|
||||
host.className = 'flash';
|
||||
var main = document.querySelector('main') || document.body;
|
||||
main.insertBefore(host, main.firstChild);
|
||||
}
|
||||
var el = document.createElement('div');
|
||||
el.className = 'flash-item' + (ok ? '' : ' err');
|
||||
el.setAttribute('role', 'status');
|
||||
el.textContent = msg;
|
||||
host.insertBefore(el, host.firstChild);
|
||||
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 3200);
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.tk-check[data-tk-done]'), function (b) {
|
||||
b.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
var now = b.getAttribute('data-tk-status') || 'todo';
|
||||
// 勾上 = 完成;已完成的再点一下 = 取消(回到「待办」)——
|
||||
// 用户可能点错了,必须能退回来(这与"没有任何撤销"的删除是两回事)。
|
||||
submitStatus(b.getAttribute('data-tk-done'), now === 'done' ? 'todo' : 'done', b);
|
||||
});
|
||||
});
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-move]'), function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var card = btn.closest('.kb-card');
|
||||
|
||||
+57
-13
@@ -23,7 +23,16 @@ $names = array_values(array_unique(array_filter($names)));
|
||||
$isChild = !empty($t['parent_id']);
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1><?= e((string)$t['title']) ?></h1>
|
||||
<h1>
|
||||
<?php /* 用户第 6 条:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框,
|
||||
这个任务就完成了。」→ 详情页也算"任何地方",标题前也给它一颗。 */ ?>
|
||||
<?php
|
||||
$tkId = (int)$t['id']; $tkStatus = (string)$t['status'];
|
||||
$tkBack = 'show'; $tkStatic = !$canWrite;
|
||||
require __DIR__ . '/../_task_check.php';
|
||||
?>
|
||||
<?= e((string)$t['title']) ?>
|
||||
</h1>
|
||||
<span class="sub"><?= e(\App\Task::statusWord((string)$t['status'])) ?></span>
|
||||
<div class="actions">
|
||||
<?php if ($canWrite): ?>
|
||||
@@ -198,18 +207,17 @@ $isChild = !empty($t['parent_id']);
|
||||
<ul class="sub-list">
|
||||
<?php foreach ($t['children'] as $c): ?>
|
||||
<li class="sub-row<?= $c['status'] === 'done' ? ' is-done' : '' ?>">
|
||||
<?php if ($canWrite): ?>
|
||||
<form method="post" action="<?= e(url('tasks/move')) ?>" class="res-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
|
||||
<input type="hidden" name="status" value="<?= $c['status'] === 'done' ? 'todo' : 'done' ?>">
|
||||
<button class="sub-check" type="submit" title="<?= $c['status'] === 'done' ? '取消完成' : '标记完成' ?>">
|
||||
<?= $c['status'] === 'done' ? '☑' : '☐' ?>
|
||||
</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<span class="sub-check is-static"><?= $c['status'] === 'done' ? '☑' : '☐' ?></span>
|
||||
<?php endif; ?>
|
||||
<?php /* v24 第 25 批 用户第 6 条:子任务的复选框**改用全站那一颗**
|
||||
—— 原来是"每行一个 form + ☐/☑ 字符":
|
||||
· 每行一个 form = 子任务一多就是几十个 form(名册页那个坑);
|
||||
· ☐/☑ 是普通字符,跨字体基线会歪,且与看板上的复选框长得不一样。
|
||||
现在卡片上只放 data-tk-done,表单全页一份(见页尾)。 */ ?>
|
||||
<?php
|
||||
$tkId = (int)$c['id']; $tkStatus = (string)$c['status'];
|
||||
$tkBack = 'show'; $tkStatic = !$canWrite;
|
||||
$tkLabel = '子任务' . (string)$c['title'];
|
||||
require __DIR__ . '/../_task_check.php';
|
||||
?>
|
||||
<a class="sub-title" href="<?= e(url('tasks/show', ['id' => (int)$c['id']])) ?>">
|
||||
<?= e((string)$c['title']) ?>
|
||||
</a>
|
||||
@@ -360,3 +368,39 @@ $isChild = !empty($t['parent_id']);
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* 完成复选框的**全页一份**表单(用户第 6 条)—— 本页可能出现多颗
|
||||
(任务自己 + 每条子任务),所以更要"卡片上只放数据、表单只此一份"。 */ ?>
|
||||
<?php if ($canWrite): ?>
|
||||
<form method="post" action="<?= e(url('tasks/status')) ?>" id="taskDoneForm" class="hidden-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" id="doneId">
|
||||
<input type="hidden" name="status" id="doneStatus">
|
||||
<input type="hidden" name="back" id="doneBack" value="show">
|
||||
</form>
|
||||
<?php /* ⚠️ 本页的复选框走**原生提交**(不 AJAX):
|
||||
详情页是一次性动作,改完最常见的是"看一眼就返回";
|
||||
就地改反而要在页面上同步三处(标题旁的状态、kv 里的状态、子任务进度)。
|
||||
原生提交 + back=show → 服务端 302 回本页,状态永远与服务端一致。
|
||||
代价只是刷一次页 —— 详情页本来就"打开看某一条",没有滚动位置要保住
|
||||
(那是看板/首页才有的诉求)。
|
||||
⚠️ 但**不能只渲染 form 不绑事件**:那样点了按钮毫无反应
|
||||
("按钮坏了"比"刷一下"糟得多)。这里只做一件最小的事:
|
||||
把"点的是哪一条 / 改成什么"灌进表单再提交。 */ ?>
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('taskDoneForm');
|
||||
if (!form) { return; }
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.tk-check[data-tk-done]'), function (b) {
|
||||
b.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
var now = b.getAttribute('data-tk-status') || 'todo';
|
||||
document.getElementById('doneId').value = b.getAttribute('data-tk-done');
|
||||
document.getElementById('doneStatus').value = (now === 'done') ? 'todo' : 'done';
|
||||
document.getElementById('doneBack').value = b.getAttribute('data-tk-back') || 'show';
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
Reference in New Issue
Block a user