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:
@@ -37,6 +37,13 @@
|
||||
RewriteRule ^nav/?$ nav_show.php [L,QSA]
|
||||
</IfModule>
|
||||
|
||||
# ⚠️ 导航图标与展示页头像**不需要重写规则**(v24 第 25 批):
|
||||
# nav_icon.php / nav_avatar.php 是站点根下的**真实文件**,Apache 直接就能执行。
|
||||
# 展示页里的地址由 Nav::abs() 拼成 `<base_url>/nav_icon.php?f=…`,落在站点根,
|
||||
# 不经过 /nav/ 这个重写前缀 —— 所以上面那两条规则一个字都不用动。
|
||||
# (这正是选"站点根下的两个独立入口"而不是"塞进 /nav/ 子路径"的原因:
|
||||
# 塞进去就得再加两条重写规则,而重写规则的顺序语义已经踩过一次坑了。)
|
||||
|
||||
# ⚠️⚠️ DAV 的 Basic 鉴权**必须**靠这一行才能在 Apache 上工作:
|
||||
# Apache 默认**不把 `Authorization` 头交给 PHP-FPM** → `$_SERVER['PHP_AUTH_USER']`
|
||||
# 和 `HTTP_AUTHORIZATION` 都是 null → /dav/ 与 /carddav/ 无论口令填得多正确都 401。
|
||||
|
||||
+143
-4
@@ -62,7 +62,8 @@ final class Anniversary
|
||||
/**
|
||||
* 新建 / 修改一条纪念日。
|
||||
*
|
||||
* @param array $d title / repeat_type / month / day / kind / note / person_id / leap
|
||||
* @param array $d title / repeat_type / month / day / kind / note / leap
|
||||
* + **person_ids[]**(v24 第 25 批:可以关联多个人)
|
||||
* @return int 记录 id
|
||||
*/
|
||||
public static function save(int $familyId, array $d, int $id = 0): int
|
||||
@@ -80,15 +81,47 @@ final class Anniversary
|
||||
$day = max(1, min(31, (int)($d['day'] ?? 1)));
|
||||
$kind = mb_substr(trim((string)($d['kind'] ?? '')), 0, 40, 'UTF-8');
|
||||
$note = trim((string)($d['note'] ?? ''));
|
||||
$pid = (int)($d['person_id'] ?? 0) ?: null;
|
||||
$leap = !empty($d['leap']) ? 1 : 0;
|
||||
|
||||
// ---- 关联的人(v24 第 25 批 用户第 2 条:一人 → 多人)----
|
||||
//
|
||||
// ⚠️ 兼容老表单:只传 `person_id`(单值)时也认,包成一个人的数组。
|
||||
// 这样任何还没来得及改的调用点(老链接、外部脚本)都不会静默丢人。
|
||||
$pids = [];
|
||||
if (array_key_exists('person_ids', $d)) {
|
||||
foreach ((array)$d['person_ids'] as $x) {
|
||||
$x = (int)$x;
|
||||
if ($x > 0) {
|
||||
$pids[] = $x;
|
||||
}
|
||||
}
|
||||
} elseif (array_key_exists('person_id', $d)) {
|
||||
$x = (int)$d['person_id'];
|
||||
if ($x > 0) {
|
||||
$pids[] = $x;
|
||||
}
|
||||
}
|
||||
$pids = array_values(array_unique($pids));
|
||||
// 只认这个家庭里的人(表单可以改,不能凭它挂到别人家的人身上)
|
||||
if ($pids) {
|
||||
$in = implode(',', array_fill(0, count($pids), '?'));
|
||||
$st = $db->prepare("SELECT id FROM persons WHERE family_id = ? AND id IN ($in)");
|
||||
$st->execute(array_merge([$familyId], $pids));
|
||||
$ok = array_map('intval', array_column($st->fetchAll(), 'id'));
|
||||
$pids = array_values(array_intersect($pids, $ok));
|
||||
}
|
||||
|
||||
// ⚠️ 老列 person_id 继续写**第一个**关联人(不写 NULL):
|
||||
// 它是老代码/老库的兜底读点,写成 NULL 会让"没升级到的读点"少一个人。
|
||||
$pid = $pids[0] ?? null;
|
||||
|
||||
if ($id > 0) {
|
||||
$db->prepare(
|
||||
'UPDATE anniversaries SET title=?, repeat_type=?, month=?, day=?, is_leap=?,
|
||||
kind=?, note=?, person_id=? WHERE id=? AND family_id=?'
|
||||
)->execute([$title, $repeat, $month, $day, $leap, $kind !== '' ? $kind : null,
|
||||
$note !== '' ? $note : null, $pid, $id, $familyId]);
|
||||
self::savePeople($id, $pids);
|
||||
return $id;
|
||||
}
|
||||
$db->prepare(
|
||||
@@ -97,12 +130,101 @@ final class Anniversary
|
||||
VALUES (?,?,?,?,?,?,?,?,?)'
|
||||
)->execute([$familyId, $title, $repeat, $month, $day, $leap,
|
||||
$kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid]);
|
||||
return (int)$db->lastInsertId();
|
||||
$newId = (int)$db->lastInsertId();
|
||||
self::savePeople($newId, $pids);
|
||||
return $newId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 整组替换某个纪念日关联的人(v24 第 25 批)。
|
||||
*
|
||||
* 「整组替换」而不是"逐条增删":表单交上来的就是**一份完整名单**,
|
||||
* 逐条比对会产生"先删后加"的中间态;一次 DELETE + 一次 INSERT 是确定结果。
|
||||
*/
|
||||
public static function savePeople(int $anniversaryId, array $personIds): int
|
||||
{
|
||||
$db = Database::get();
|
||||
$db->prepare('DELETE FROM anniversary_people WHERE anniversary_id = ?')->execute([$anniversaryId]);
|
||||
if (!$personIds) {
|
||||
return 0;
|
||||
}
|
||||
$ins = $db->prepare(
|
||||
'INSERT IGNORE INTO anniversary_people (anniversary_id, person_id, sort_order) VALUES (?,?,?)'
|
||||
);
|
||||
$n = 0;
|
||||
$i = 0;
|
||||
foreach ($personIds as $pid) {
|
||||
$pid = (int)$pid;
|
||||
if ($pid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$i += 10;
|
||||
$ins->execute([$anniversaryId, $pid, $i]);
|
||||
$n++;
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个纪念日关联的人(**含兜底**)。
|
||||
*
|
||||
* ⚠️ 兜底逻辑(v24 第 25 批升级过渡期必须有):
|
||||
* 新表里没有行时,回落到老列 `person_id` —— 这样"升级跑到一半"
|
||||
* 或"老数据没回填成功"时,页面上仍然看得到那个人,而不是静默变成"没关联"。
|
||||
*
|
||||
* @return int[] person id(按 sort_order)
|
||||
*/
|
||||
public static function peopleOf(array $row): array
|
||||
{
|
||||
static $cache = [];
|
||||
$aid = (int)($row['id'] ?? 0);
|
||||
if ($aid <= 0) {
|
||||
return [];
|
||||
}
|
||||
if (array_key_exists($aid, $cache)) {
|
||||
return $cache[$aid];
|
||||
}
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT person_id FROM anniversary_people WHERE anniversary_id = ? ORDER BY sort_order, person_id'
|
||||
);
|
||||
$st->execute([$aid]);
|
||||
$out = array_map('intval', array_column($st->fetchAll(), 'person_id'));
|
||||
if (!$out) {
|
||||
$one = (int)($row['person_id'] ?? 0);
|
||||
if ($one > 0) {
|
||||
$out = [$one];
|
||||
}
|
||||
}
|
||||
$cache[$aid] = $out;
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批纪念日的关联人:`anniversary_id => [person_id, ...]`(避免逐行查库)。
|
||||
*/
|
||||
public static function peopleMap(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', $ids)));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT anniversary_id, person_id FROM anniversary_people
|
||||
WHERE anniversary_id IN ($in) ORDER BY anniversary_id, sort_order, person_id"
|
||||
);
|
||||
$st->execute($ids);
|
||||
$out = [];
|
||||
foreach ($st->fetchAll() as $r) {
|
||||
$out[(int)$r['anniversary_id']][] = (int)$r['person_id'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function remove(int $familyId, int $id): void
|
||||
{
|
||||
Attachment::removeAll(self::ATTACH, $id);
|
||||
// 关联表由外键 ON DELETE CASCADE 一起走;这里不必手动删
|
||||
Database::get()->prepare('DELETE FROM anniversaries WHERE id = ? AND family_id = ?')
|
||||
->execute([$id, $familyId]);
|
||||
}
|
||||
@@ -189,13 +311,30 @@ final class Anniversary
|
||||
*/
|
||||
public static function split(int $familyId, int $viewerId): array
|
||||
{
|
||||
$rows = self::list($familyId);
|
||||
// 一次把所有人的关联人查出来(避免逐行查库)——v24 第 25 批 用户第 2 条
|
||||
$pmap = self::peopleMap(array_column($rows, 'id'));
|
||||
$custom = [];
|
||||
foreach (self::list($familyId) as $r) {
|
||||
foreach ($rows as $r) {
|
||||
$r['source'] = 'custom';
|
||||
$r['person_name'] = '';
|
||||
$aid = (int)$r['id'];
|
||||
if (!empty($pmap[$aid])) {
|
||||
$r['person_ids'] = $pmap[$aid];
|
||||
} elseif ((int)($r['person_id'] ?? 0) > 0) {
|
||||
// 兜底:新表没有行就看老列(升级过渡期)
|
||||
$r['person_ids'] = [(int)$r['person_id']];
|
||||
} else {
|
||||
$r['person_ids'] = [];
|
||||
}
|
||||
$custom[] = $r;
|
||||
}
|
||||
$derived = self::birthdays($familyId, $viewerId);
|
||||
// 派生行(名册生日)本来就知道是谁,统一挂上 person_ids,模板无须分两条路
|
||||
foreach ($derived as &$dv) {
|
||||
$dv['person_ids'] = (int)($dv['person_id'] ?? 0) > 0 ? [(int)$dv['person_id']] : [];
|
||||
}
|
||||
unset($dv);
|
||||
|
||||
$byMD = static function (array $a, array $b): int {
|
||||
return ((int)($a['month'] ?? 0) <=> (int)($b['month'] ?? 0))
|
||||
|
||||
+106
-3
@@ -543,10 +543,48 @@ final class Nav
|
||||
return $host ? (string)preg_replace('/^www\./i', '', $host) : '';
|
||||
}
|
||||
|
||||
/** 图标文件名(按 URL 取 md5,与 SimpleNav 一致) */
|
||||
/**
|
||||
* 图标键(按 URL 取 md5)—— **必须与 SimpleNav 的 `icon_key()` 逐字一致**。
|
||||
*
|
||||
* 🔴🔴 v24 第 25 批 用户第 9 条修(搬运失真,导致"图标全都没有"):
|
||||
*
|
||||
* 原实现写成了 `md5(self::normUrl($url))` —— 看起来"合理",但 SimpleNav 的
|
||||
* `icon_key()` 做的归一化比 `normUrl()` 多得多,两者算出来的键**不一样**:
|
||||
* · SimpleNav:scheme/host **转小写**、**补上空路径为 `/`**、保留 port/query/fragment;
|
||||
* · 原来这里:什么都不做(只用 normUrl 补 `http://` 前缀)。
|
||||
* 于是 `https://otrust.ouc.edu.cn`(无路径)在两边分别得到
|
||||
* `921cbff230c762f21ec5b6ffbfa1da63` 与 `0c4227cc33e0b0ab680aaf24031965f3`
|
||||
* —— **只要 URL 里有一个大写字母或缺一个斜杠,文件名就对不上**。
|
||||
*
|
||||
* 后果(用户看到的现象):从 SimpleNav 搬过来的站点,`icon` 列里存着的文件名
|
||||
* 指向的文件其实叫另一个名字,`iconUrl()` 里 `is_file()` 判假 → 返回空串 →
|
||||
* 前端退化成"首字母色块",**一个图标都显示不出来**。
|
||||
*
|
||||
* ⚠️ 另外注意:**实际文件名是 `md5(本函数的结果)`(二次哈希)** ——
|
||||
* 这是 SimpleNav 的历史遗留(只为让文件名定长),但它确实在文件名里,
|
||||
* 所以搬迁 / 读写图标文件时**不要**自己再哈希或去掉这一层。
|
||||
* 见 NavFetch::download_best_icon 的落盘处(它用的是 `md5($key)`)。
|
||||
*/
|
||||
public static function iconKey(string $url): string
|
||||
{
|
||||
return md5(self::normUrl($url));
|
||||
$u = trim($url);
|
||||
if ($u === '') {
|
||||
return '';
|
||||
}
|
||||
if (!preg_match('#^https?://#i', $u)) {
|
||||
$u = 'http://' . $u;
|
||||
}
|
||||
$p = @parse_url($u);
|
||||
if (!is_array($p) || empty($p['host'])) {
|
||||
return md5($u); // 解析不出来就整串兜底,至少不会跟别人撞
|
||||
}
|
||||
$norm = strtolower((string)($p['scheme'] ?? '')) . '://'
|
||||
. strtolower((string)$p['host'])
|
||||
. (empty($p['port']) ? '' : ':' . $p['port'])
|
||||
. (isset($p['path']) && $p['path'] !== '' ? $p['path'] : '/')
|
||||
. (isset($p['query']) ? '?' . $p['query'] : '')
|
||||
. (isset($p['fragment']) ? '#' . $p['fragment'] : '');
|
||||
return md5($norm);
|
||||
}
|
||||
|
||||
/** 名称首字配色(无图标时的回退底色)—— 照搬 SimpleNav 的色板 */
|
||||
@@ -587,7 +625,72 @@ final class Nav
|
||||
if (!is_file($abs)) {
|
||||
return '';
|
||||
}
|
||||
return url('/' . self::ICON_URL . '/' . rawurlencode($file)) . '?v=' . filemtime($abs);
|
||||
// 🔴 走 **nav_icon.php** 这个免登录公开出口,**不能**直接给 `uploads/…` 的地址:
|
||||
// uploads/ 整个目录是 `Require all denied`(线上实测直接取会 403),
|
||||
// 而展示页是免登录的、拿不到 media.php 需要的登录态。
|
||||
// 详见 nav_icon.php 的文件头(两个约束为什么会撞在一起)。
|
||||
// ⚠️ 管理页(登录态)也用同一条:一个图标地址两套出口,
|
||||
// "管理页看得见、展示页看不见"这种半生效是最难查的。
|
||||
return self::abs('/nav_icon.php') . '?f=' . rawurlencode($file) . '&v=' . filemtime($abs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把站内相对地址变成**绝对地址**(v24 第 25 批 用户第 9 条修)。
|
||||
*
|
||||
* 🔴🔴 为什么必须有这个函数(这是本批最要紧的一个根因):
|
||||
*
|
||||
* 家书的 `url()` 产出的是 `index.php?r=xxx`、`avatar_url()` 产出 `media.php?k=…`
|
||||
* —— 全是**相对**地址(只有 `asset()` 是 `/assets/…` 绝对路径)。
|
||||
* 它们在站点根(`https://host/index.php?...`)上没问题,
|
||||
* 但**导航展示页在 `/nav/` 这个子目录下**:浏览器会把相对地址拼在**当前目录**上,
|
||||
* 于是变成 `/nav/index.php?r=…`、`/nav/media.php?k=…` —— **线上实测全 404**。
|
||||
*
|
||||
* 症状正是用户报的那三条一起出现(同一个根因):
|
||||
* · 展示页头像不显示(`/nav/media.php?k=avatar.jpg` → 404)
|
||||
* · 点「登录」404(`/nav/index.php?r=auth/login` → 404)
|
||||
* · 搜索联想请求打到 `/nav/…`(同样 404,下拉永远空)
|
||||
*
|
||||
* 修法:**凡是展示页要输出的自引用地址,一律经这里拼上 base_url**。
|
||||
* ⚠️ 只在**免登录展示页**用得到(它是唯一不在站点根下的页面)——
|
||||
* 不要拿它去替换全站的 `url()`:那会把所有站内链接都变成绝对地址,
|
||||
* `?r=` 那套路由本来就不需要域名,改了反而多一处配置依赖。
|
||||
*
|
||||
* @param string $path 以 `/` 开头的站内路径,或 `index.php?r=…` 这种相对串
|
||||
*/
|
||||
public static function abs(string $path): string
|
||||
{
|
||||
$path = '/' . ltrim($path, '/');
|
||||
|
||||
/* 🔴 优先用**当前请求的 Host**,不要写死配置里的 base_url。
|
||||
*
|
||||
* 为什么(2026-10-07 第 25 批实测):本地真机探针起的是**随机端口**的
|
||||
* `php -S 127.0.0.1:<port>`,而 `config.php` 的 `base_url` 写的是
|
||||
* `http://127.0.0.1:8791`。展示页把 CSS 拼成 `…:8791/assets/nav-show.css`
|
||||
* → 与探针那个端口对不上 → **样式整份取不到** →
|
||||
* 症状是"登录按钮落到左边、头像判据也跟着偏",看起来像**布局坏了**,
|
||||
* 其实是"资源地址指到了另一个端口"(最难查的一类:页面 200、HTML 全对)。
|
||||
* ⇒ 绝对化要以"用户实际访问的那个 host"为准 —— 这也是**唯一正确**的口径:
|
||||
* 线上有多个入口(域名 / IP / 宝塔测试域名)时,写死 base_url 会把
|
||||
* 另外那些入口的静态资源全指到 base_url 那个域上去。
|
||||
*
|
||||
* ⚠️ 但 Host 头是**客户端可控**的(Host 注入)—— 所以只接受**形状合法**的
|
||||
* host:字母数字点横线、可带端口。不合法就退回 base_url。
|
||||
*/
|
||||
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
|
||||
if ($host !== '' && preg_match('/^[A-Za-z0-9.\-]+(:\d+)?$/', $host)) {
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'
|
||||
|| (int)($_SERVER['SERVER_PORT'] ?? 0) === 443;
|
||||
return ($https ? 'https://' : 'http://') . $host . $path;
|
||||
}
|
||||
|
||||
$base = rtrim((string)fb_config('base_url', ''), '/');
|
||||
if ($base === '') {
|
||||
// 没配 base_url(极少数自建环境)→ 退化成"从站点根算"的绝对路径。
|
||||
// 仍然比相对地址安全:相对地址在 /nav/ 下必错,而 /index.php 至少指对了。
|
||||
return $path;
|
||||
}
|
||||
return $base . $path;
|
||||
}
|
||||
|
||||
public static function deleteIconFile(string $file): void
|
||||
|
||||
@@ -325,12 +325,40 @@ final class PersonRoster
|
||||
* 老库里的 group_id / sort_order 也一并搬——分类语义从"人的属性"变成"我和他的关系",
|
||||
* 但**落在每个人名册里的值不变**,所以看起来完全一样。
|
||||
*
|
||||
* 🔴🔴 **自守卫:只在"还没迁移过"的库上跑**(v24 第 25 批 用户第 4 条修的真 bug)。
|
||||
*
|
||||
* 用户原话:「名册中,有一些人,我从名册中移出去了,结果过一段时间就又回来了,
|
||||
* 不知道什么原因,需要查一下。」
|
||||
*
|
||||
* 根因就在这里:本方法原先是**无条件跑**的,而它在**每一次** `Schema::ensure()` 里
|
||||
* 都被调用一次 —— `ensure()` 的触发判据是「Schema.php 源码指纹变了」(见
|
||||
* Schema::fingerprint 的说明),也就是说**任何一次改 Schema 的部署之后,
|
||||
* 所有已迁移完成的老库都会再被重灌一遍全家的成员**。
|
||||
* 于是用户「从我的名册移走」的那个人,在一段时间后(下一次部署 / 下一次改结构)
|
||||
* **自己回来了** —— 而且因为 INSERT IGNORE 只看唯一键,回来的那条 source 还是
|
||||
* 'seeded',看不出是刚被重灌的。
|
||||
*
|
||||
* ⚠️ 线上实测证据(2026-10-07):线上运行一次本方法的**前后对比**是
|
||||
* `total 1621 → 1622`(真的又插回来一条),而此前 members=811、
|
||||
* 名册总行数 1621 > 811×2 —— 差值正是历次重灌留下的。
|
||||
*
|
||||
* 修法:**判据用"persons 上那两个旧列还在不在"** ——
|
||||
* `group_id` / `sort_order` 是 **v5 迁移的输入**,迁移一跑完它们就被
|
||||
* `dropColumnIfExists` 删掉了(见 Schema::ensure 里紧跟着的两行)。
|
||||
* 所以「两列都不在」= 这个库早就迁移过了 = 本方法无事可做。
|
||||
* 这样它天然只在**真正需要它的那一次**(老库首次升级)生效,
|
||||
* 不需要任何哨兵键,也不会因为"下次改 Schema"再跑一遍。
|
||||
*
|
||||
* 幂等:靠 uk_roster_owner_person 唯一键 + INSERT IGNORE,重复跑不会插重。
|
||||
*/
|
||||
public static function seedFromPersons(\PDO $db): void
|
||||
{
|
||||
$hasGroup = self::hasColumn($db, 'persons', 'group_id');
|
||||
$hasSort = self::hasColumn($db, 'persons', 'sort_order');
|
||||
// 🔴 自守卫:旧列都没了 = 已经迁移过 = 绝不能重灌(用户移走的人会回来)
|
||||
if (!$hasGroup && !$hasSort) {
|
||||
return;
|
||||
}
|
||||
$oldGroup = $hasGroup ? 'p.group_id' : 'NULL';
|
||||
$oldSort = $hasSort ? 'p.sort_order' : 'p.id';
|
||||
|
||||
|
||||
+29
-3
@@ -186,7 +186,12 @@ final class Region
|
||||
foreach (['中华人民共和国', '中国'] as $c) {
|
||||
if (mb_strpos($text, $c) === 0) {
|
||||
$out['country'] = '中国';
|
||||
$text = ltrim(mb_substr($text, mb_strlen($c)), ' ,,');
|
||||
// ⚠️ 同下面 `parse()` 末尾那条注释:`ltrim(…, ' ,,')` 的 charlist
|
||||
// **按字节**匹配,「,」= EF BC 8C → 字节 EF/BC/8C 也进了可剥集合,
|
||||
// 会把以这些字节开头的汉字**剥掉首字节** → 非法 UTF-8。
|
||||
// 改成字符级(`/u`)去空白与中文逗号。
|
||||
$text = (string)preg_replace(
|
||||
'/^[\s,,]+/u', '', mb_substr($text, mb_strlen($c)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -228,7 +233,27 @@ final class Region
|
||||
}
|
||||
}
|
||||
|
||||
$rest = trim($rest, " ,,。;;\t\n\r\0\x0B");
|
||||
/* 🔴🔴 这里**绝不能**写成 `trim($rest, " ,,。;;\t\n\r\0\x0B")`
|
||||
* (v24 第 25 批 2026-10-07 在线上实测抓到的真 bug,且是**数据损坏级**的)。
|
||||
*
|
||||
* 根因:PHP 的 `trim()` 第二参数(charlist)是**按字节**匹配的,不是按字符。
|
||||
* 上面那串里带三个**多字节**中文标点:
|
||||
* 「,」= E3 80 8C、「。」= E3 80 82、「;」= EF BC 9B
|
||||
* 于是字节集合里混进了 **80 / 82 / 8C / 9B / BC …** ——
|
||||
* 任何一个**尾字节**恰好等于这些值的汉字,都会被\"剥掉最后一字节\":
|
||||
* 「层」= E5 B1 82 → 被剥成 E5 B1(**非法 UTF-8**)
|
||||
* 「,」类标点本身也会被剥掉前两字节,只剩 E3。
|
||||
* 症状链条(线上实测):`backfillAddress` 拿这个坏 detail 去 UPDATE
|
||||
* → `SQLSTATE[HY000] 1366 Incorrect string value: '\xE5\xB1' for column 'address'`
|
||||
* → 升级步骤被 tryStep 捕获跳过 → **这条升级从上线起就没成功过**,
|
||||
* 而且 request 每来一次就报一次错(日志一直刷,没人注意到)。
|
||||
* ⚠️ 更阴的地方:它**只在特定数据上发作**(地址里带「层」「,」(半角在
|
||||
* 中间不影响)等尾字节命中集合的字),本地夹具没有这种地址 → 本地全绿。
|
||||
*
|
||||
* 正确做法:先把中文标点(多字节)**按字符**去掉,剩下的 ASCII 空白再交给 trim。
|
||||
* 顺序不能反 —— 先 trim 会把中文标点剥成半个字节,再去字符级就没救了。
|
||||
*/
|
||||
$rest = (string)preg_replace('/^[\s,,。;;、]+|[\s,,。;;、]+$/u', '', $rest);
|
||||
if ($rest !== '') {
|
||||
$out['detail'] = $rest;
|
||||
}
|
||||
@@ -286,7 +311,8 @@ final class Region
|
||||
continue; // 太短会误吃(如「东区」)
|
||||
}
|
||||
if (mb_strpos($text, $cand) === 0) {
|
||||
return ltrim(mb_substr($text, mb_strlen($cand)), ' ,,');
|
||||
return (string)preg_replace(
|
||||
'/^[\s,,]+/u', '', mb_substr($text, mb_strlen($cand)));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
+44
-1
@@ -104,7 +104,7 @@ final class Schema
|
||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||
*/
|
||||
public const VERSION = '24';
|
||||
public const VERSION = '25';
|
||||
|
||||
/**
|
||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||
@@ -1699,6 +1699,49 @@ SQL);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 纪念日关联**多个人**(版本 25,用户 2026-10-07 第 2 条)----
|
||||
//
|
||||
// 用户原话:「纪念日模块,每个纪念日现在只能关联一个人,应该可以关联多个人。
|
||||
// 和选择观影人一样,多选模式。」
|
||||
//
|
||||
// ⚠️ 建表放在 ensure() 里(而不是 statements() 的 return 数组):
|
||||
// 它有两个外键指向 `anniversaries` 与 `persons` —— 那两张表由
|
||||
// statements() 先建出来,这里紧随其后才安全;而且这一段本来就在
|
||||
// "改结构"的位置上(statements() 只管最终形态的建表)。
|
||||
// ⚠️ **不改 anniversaries.person_id 那一列**(保留它、继续写第一个关联人):
|
||||
// 它是老库/老代码的读点,删掉会让"没跟上升级"的地方静默少一个人。
|
||||
// 改成"以新表为准、老列只做兜底"—— 见 Anniversary::peopleOf()。
|
||||
$db->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `anniversary_people` (
|
||||
`anniversary_id` int(10) unsigned NOT NULL,
|
||||
`person_id` int(10) unsigned NOT NULL,
|
||||
`sort_order` INT NOT NULL DEFAULT 10 COMMENT '列表里的先后(关联多个人时按它排)',
|
||||
PRIMARY KEY (`anniversary_id`, `person_id`),
|
||||
KEY `idx_annp_person` (`person_id`),
|
||||
CONSTRAINT `fk_annp_ann` FOREIGN KEY (`anniversary_id`) REFERENCES `anniversaries` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_annp_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='纪念日关联的人(v24 第 25 批 用户第 2 条:一个纪念日可以关联多个人)'
|
||||
SQL);
|
||||
|
||||
// 回填:把老库里 `person_id` 非空的那一条搬进新表。
|
||||
// ⚠️ 必须**只在还没有任何新表行时**搬(用 NOT EXISTS 判),否则
|
||||
// 用户在新界面上改过关联的人之后,下一次请求又会被老列覆盖回去
|
||||
// (与第 4 条"移出的人又回来"同一个类别的坑:无条件回填 = 删除被撤销)。
|
||||
self::tryStep('纪念日关联人搬到新表(一人 → 多人)', static function () use ($db) {
|
||||
if (!self::hasTable($db, 'anniversary_people')) {
|
||||
return;
|
||||
}
|
||||
$db->exec(
|
||||
"INSERT IGNORE INTO `anniversary_people` (anniversary_id, person_id, sort_order)
|
||||
SELECT a.id, a.person_id, 10
|
||||
FROM `anniversaries` a
|
||||
WHERE a.person_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM `anniversary_people` p
|
||||
WHERE p.anniversary_id = a.id)"
|
||||
);
|
||||
});
|
||||
|
||||
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
||||
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
||||
if (self::$stepErrors) {
|
||||
|
||||
@@ -5,6 +5,35 @@
|
||||
* 看板三列直接落在 `status` 上(todo / doing / done),不另存"列"字段 ——
|
||||
* "列"和"状态"本来就是一件事,存两份必然对不上(拖到 done 了状态还是 doing 之类)。
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 「待办」与「进行中」到底差什么(v24 第 25 批 用户第 6 条要求说清楚)
|
||||
*
|
||||
* 用户原话:「任务的状态也不太对,待办和进行中,有什么区别?去网上查查,
|
||||
* 别人都是怎么设置状态的。」
|
||||
*
|
||||
* 查证结果(这个答得干脆,业界早就收敛了):
|
||||
* 主流做法就是 Kanban 的**三态**,而且**列名与状态名是同一件事**:
|
||||
* · **To Do / 待办** —— 已记下来、**还没动手**。它是一份"排队"。
|
||||
* · **In Progress / 进行中** —— **已经动手了**(哪怕只做了两分钟)。
|
||||
* · **Done / 已完成** —— 做完并验收。
|
||||
* 出处:Todoist 的 Kanban 指南、Wrike 的 Kanban 词条("What Are The Kanban
|
||||
* To Do, Doing, Done Lists?")都把这三列定义为**工作流阶段**;
|
||||
* ONES 的中文词条更直白:「待处理=已创建但尚未开始」「进行中=正在执行过程中,
|
||||
* 让其他人知道**已有人在处理**,避免重复工作」。
|
||||
*
|
||||
* ⇒ 所以分界线是**"有没有开始"**,不是"重不重要"、也不是"谁在管":
|
||||
* 待办 = 队列里排着(我还没碰它)
|
||||
* 进行中 = 手上有活(**别人不该再认领**)
|
||||
* 两条纪律(Kanban 的 WIP 限制):
|
||||
* ① 「进行中」**不要堆太多** —— 那是"同时开了 N 摊",做不完;
|
||||
* ② 首页只显示「进行中」(+ 已逾期的待办),因为"我手头在做什么"才是要看的。
|
||||
* 这正是 v24 第 14 批 用户第 2 条那句「按状态取,不看板取」的来由。
|
||||
*
|
||||
* ⚠️ **不做**「待确认 / 待审核 / 已暂停」这类扩展状态:
|
||||
* 那是团队协作的产物(要有人审、有人批)。家庭场景三个人做家务,
|
||||
* 多一个状态就多一次"该选哪个"的犹豫。三态够用,且与业界口径一致。
|
||||
* 真需要更细,用**看板**分("属于哪摊事")而不是加压状态 —— 两个维度本来就分开。
|
||||
*
|
||||
* 两级:只做父任务 / 子任务两层(parent_id)。**不做无限层级** ——
|
||||
* 家庭任务("周末大扫除"下面挂"擦玻璃""洗车")两层足够,
|
||||
* 无限层级要递归查询 + 缩进渲染,代价远大于收益。
|
||||
@@ -24,6 +53,39 @@ final class Task
|
||||
{
|
||||
public const STATUS = ['todo' => '待办', 'doing' => '进行中', 'done' => '已完成'];
|
||||
|
||||
/**
|
||||
* 每个状态「到底是什么意思」——**给界面上的人看**(v24 第 25 批 用户第 6 条)。
|
||||
*
|
||||
* 用户原话:「待办和进行中,有什么区别?」——他不知道区别,就是这一处没写清楚。
|
||||
* 所以这句话要出现在**他看到状态的地方**:卡片上的 title、状态说明、
|
||||
* 以及任务页头那句总说明(见 templates/tasks/index.php)。
|
||||
*
|
||||
* ⚠️ 文案刻意用"我/手头"这种第一人称口语:这是家庭场景,
|
||||
* 写成"尚未开始执行"那种公文腔他自己也不会去读。
|
||||
*/
|
||||
public const STATUS_HINT = [
|
||||
'todo' => '记下来了,还没动手 —— 排队等着',
|
||||
'doing' => '已经动手了(哪怕刚开始)—— 别人就别再认领了',
|
||||
'done' => '做完了',
|
||||
];
|
||||
|
||||
/** 状态的一句话解释(界面上用它做 title / 说明) */
|
||||
public static function statusHint(string $k): string
|
||||
{
|
||||
return self::STATUS_HINT[$k] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 界面上「点一下就能完成」的那颗复选框要提交的目标状态。
|
||||
*
|
||||
* 用户原话:「任务相关的模块,所有的任务,前面都带一个复选框,在任何地方,
|
||||
* 点这个复选框,这个任务就完成了。」
|
||||
* ⇒ 所以勾上 = `done`(**不是**"推进到下一个状态")。
|
||||
* ⚠️ 与卡片上那颗状态徽标(点一下轮转 todo→doing→done)刻意不同:
|
||||
* 复选框只有一个意思「完成」,徽标是"改状态"。两个控件、两种语义。
|
||||
*/
|
||||
public const DONE_STATUS = 'done';
|
||||
|
||||
public const REPEAT = [
|
||||
'' => '不循环',
|
||||
'day' => '每天',
|
||||
|
||||
+478
@@ -50,6 +50,21 @@ final class WeRead
|
||||
/** 用户设置里存加密 Key 的键名 */
|
||||
public const SETTING_KEY = 'weread_api_key';
|
||||
|
||||
/** 上次自动同步的时间戳(存秒;不是字符串,别再解析一次) */
|
||||
public const SYNC_AT_KEY = 'weread_sync_at';
|
||||
|
||||
/** 自动同步总开关('1' 开 / '0' 关) */
|
||||
public const AUTO_KEY = 'weread_auto_sync';
|
||||
|
||||
/**
|
||||
* 自动同步的最小间隔(秒)——**默认 6 小时**。
|
||||
*
|
||||
* ⚠️ 为什么是 6 小时而不是"每天一次":
|
||||
* 读书是随时发生的。用户下午读完一本,晚上打开家书希望"它已经是读完的"。
|
||||
* 而 6 小时也足够克制 —— 一天最多 4 次 API 调用,不会触发频率限制。
|
||||
*/
|
||||
public const SYNC_TTL = 21600;
|
||||
|
||||
// ================================================================
|
||||
// Key 的存取(加密)
|
||||
// ================================================================
|
||||
@@ -325,6 +340,239 @@ final class WeRead
|
||||
return ['ok' => true, 'items' => $items, 'error' => ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一本书的**基本信息**(v24 第 25 批 用户第 8 条:封面看不到)。
|
||||
*
|
||||
* 用户原话:「微信读书导入,需要能够做到定期完成,自动同步。现在可以导入了,
|
||||
* 不过**封面图看不到**,另外也缺少一些对微信读书的统计功能,需要加强。」
|
||||
*
|
||||
* 根因:导入时只建了 title + author + status,**从来没写过 cover_key** ——
|
||||
* 而官方 ` /book/info ` 明明给了 `cover`(封面 URL)、`isbn`、`publisher`、
|
||||
* `category`、`intro`、`newRating`、`wordCount`。
|
||||
* 所以书架里那一排全是「📖 空封面」。这里把该拿的一次拿齐。
|
||||
*
|
||||
* @return array{ok:bool,book:array,error:string}
|
||||
*/
|
||||
public static function bookInfo(int $personId, string $bookId): array
|
||||
{
|
||||
$r = self::call($personId, '/book/info', ['bookId' => $bookId]);
|
||||
if (!$r['ok']) {
|
||||
return ['ok' => false, 'book' => [], 'error' => $r['error']];
|
||||
}
|
||||
$d = (array)$r['data'];
|
||||
return ['ok' => true, 'error' => '', 'book' => [
|
||||
'title' => (string)($d['title'] ?? ''),
|
||||
'author' => (string)($d['author'] ?? ''),
|
||||
'translator' => (string)($d['translator'] ?? ''),
|
||||
'cover' => (string)($d['cover'] ?? ''),
|
||||
'intro' => (string)($d['intro'] ?? ''),
|
||||
'category' => (string)($d['category'] ?? ''),
|
||||
'publisher' => (string)($d['publisher'] ?? ''),
|
||||
'published' => (string)($d['publishTime'] ?? ''),
|
||||
'isbn' => (string)($d['isbn'] ?? ''),
|
||||
'wordCount' => (int)($d['wordCount'] ?? 0),
|
||||
'rating' => (int)round((int)($d['newRating'] ?? 0) / 10), // 官方是百分制
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* 阅读进度(`/book/getprogress`)—— 用来判断"这本书读完了没"。
|
||||
*
|
||||
* ⚠️ 官方文档专门强调:`progress` 是 **0~100 的整数**,而且
|
||||
* **只有 100 才代表读完**(1 表示 1%,不是 100%)。
|
||||
* 这一条极易写错(把 1 当"已读"),所以在这里就把判据封装好。
|
||||
*
|
||||
* @return array{ok:bool,progress:int,finished:bool,done_at:string,seconds:int,error:string}
|
||||
*/
|
||||
public static function progress(int $personId, string $bookId): array
|
||||
{
|
||||
$r = self::call($personId, '/book/getprogress', ['bookId' => $bookId]);
|
||||
if (!$r['ok']) {
|
||||
return ['ok' => false, 'progress' => 0, 'finished' => false,
|
||||
'done_at' => '', 'seconds' => 0, 'error' => $r['error']];
|
||||
}
|
||||
$b = (array)($r['data']['book'] ?? []);
|
||||
$p = max(0, min(100, (int)($b['progress'] ?? 0)));
|
||||
$fin = $p >= 100;
|
||||
return [
|
||||
'ok' => true,
|
||||
'error' => '',
|
||||
'progress' => $p,
|
||||
'finished' => $fin,
|
||||
'done_at' => $fin ? self::ts((int)($b['finishTime'] ?? 0)) : '',
|
||||
'seconds' => (int)($b['recordReadingTime'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 阅读统计(`/readdata/detail`)—— 用户第 8 条要的"统计功能加强"。
|
||||
*
|
||||
* 官方给的东西比本项目原来的统计丰富得多,这里只挑**能直接用**的几样:
|
||||
* · `totalReadTime` —— 该周期总时长(**秒**,官方文档反复强调别当分钟)
|
||||
* · `readDays` —— 有效阅读天数(官方口径:单日满 1 分钟)
|
||||
* · `readStat` —— 「读过 N 本 / 读完 N 本 / 阅读 N 天 / 笔记 N 条」
|
||||
* · `readLongest` —— 读得最多的书(含封面)
|
||||
* · `preferCategory` —— 偏好分类(带时长)
|
||||
* · `preferAuthor` —— 偏好作者
|
||||
*
|
||||
* ⚠️ 官方只支持**固定自然周期**(weekly/monthly/annually/overall),
|
||||
* 不支持任意起止日期。默认给 `overall`("一共读了多久"最常被问)。
|
||||
*
|
||||
* @param string $mode weekly / monthly / annually / overall
|
||||
* @return array{ok:bool,data:array,error:string}
|
||||
*/
|
||||
public static function readStats(int $personId, string $mode = 'overall', int $baseTime = 0): array
|
||||
{
|
||||
if (!in_array($mode, ['weekly', 'monthly', 'annually', 'overall'], true)) {
|
||||
$mode = 'overall';
|
||||
}
|
||||
$params = ['mode' => $mode];
|
||||
if ($baseTime > 0) {
|
||||
$params['baseTime'] = $baseTime;
|
||||
}
|
||||
$r = self::call($personId, '/readdata/detail', $params);
|
||||
if (!$r['ok']) {
|
||||
return ['ok' => false, 'data' => [], 'error' => $r['error']];
|
||||
}
|
||||
$d = (array)$r['data'];
|
||||
|
||||
// 读得最多的书(最多 10 条,官方已按 readTime 降序、且过滤掉不足 5 分钟的)
|
||||
$top = [];
|
||||
foreach ((array)($d['readLongest'] ?? []) as $it) {
|
||||
$bk = (array)($it['book'] ?? []);
|
||||
$top[] = [
|
||||
'title' => (string)($bk['title'] ?? ''),
|
||||
'author' => (string)($bk['author'] ?? ''),
|
||||
'cover' => (string)($bk['cover'] ?? ''),
|
||||
'bookId' => (string)($bk['bookId'] ?? ''),
|
||||
'seconds' => (int)($it['readTime'] ?? 0),
|
||||
'tags' => array_map('strval', (array)($it['tags'] ?? [])),
|
||||
];
|
||||
}
|
||||
// 偏好分类(带时长与本数)
|
||||
$cats = [];
|
||||
foreach ((array)($d['preferCategory'] ?? []) as $c) {
|
||||
$cats[] = [
|
||||
'name' => (string)($c['categoryTitle'] ?? ''),
|
||||
'seconds' => (int)($c['readingTime'] ?? 0),
|
||||
'books' => (int)($c['readingCount'] ?? 0),
|
||||
'val' => (int)($c['val'] ?? 0),
|
||||
];
|
||||
}
|
||||
// 偏好作者(注意:官方的 readTime 这里是**格式化字符串**,不是秒)
|
||||
$authors = [];
|
||||
foreach ((array)($d['preferAuthor'] ?? []) as $a) {
|
||||
$authors[] = [
|
||||
'name' => (string)($a['name'] ?? ''),
|
||||
'books' => (int)($a['count'] ?? 0),
|
||||
'timeWord' => (string)($a['readTime'] ?? ''),
|
||||
];
|
||||
}
|
||||
// 摘要那几行(读过/读完/阅读/笔记)
|
||||
$stat = [];
|
||||
foreach ((array)($d['readStat'] ?? []) as $s) {
|
||||
$stat[] = ['name' => (string)($s['stat'] ?? ''), 'count' => (string)($s['counts'] ?? '')];
|
||||
}
|
||||
return ['ok' => true, 'error' => '', 'data' => [
|
||||
'mode' => $mode,
|
||||
'seconds' => (int)($d['totalReadTime'] ?? 0),
|
||||
'days' => (int)($d['readDays'] ?? 0),
|
||||
'avgSec' => (int)($d['dayAverageReadTime'] ?? 0),
|
||||
'compare' => $d['compare'] ?? null,
|
||||
'stat' => $stat,
|
||||
'topBooks' => $top,
|
||||
'categories'=> $cats,
|
||||
'authors' => $authors,
|
||||
'preferWord'=> (string)($d['preferCategoryWord'] ?? ''),
|
||||
'preferTime'=> (string)($d['preferTimeWord'] ?? ''),
|
||||
'yearReport'=> (array)($d['yearReport'] ?? []),
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一张封面图**拉下来存进本站存储**(用户第 8 条:封面看不到)。
|
||||
*
|
||||
* ⚠️ 为什么不直接把官方的 `cover` URL 存进 `books.cover_key`:
|
||||
* ① 那个 URL 是**第三方 CDN**(微信读书的),可能带防盗链/时效;
|
||||
* 直接把外链塞进 cover_key,展示时要么不显示、要么哪天忽然全变破图;
|
||||
* ② 本站所有附件都走 `media.php` 代理(登录态 + 存储驱动),
|
||||
* 混一个外链进去就破了"所有媒体一个出口"这条规矩;
|
||||
* ③ 用户可能清理缓存/换账号,外链失效后书架就一排空封面。
|
||||
* ⇒ 拉下来走**与用户手传封面完全同一条路**(`Upload::image`,
|
||||
* slot = `bookcover`),这样它不仅存好了,还顺带做了 2:3 居中裁剪 ——
|
||||
* 与用户自己传的封面在页面上长得**一模一样**(否则两种来源两种比例,
|
||||
* 书架格子会被撑得参差不齐)。
|
||||
*
|
||||
* @return string 存好的 cover_key(失败返回 '')
|
||||
*/
|
||||
public static function fetchCover(int $familyId, string $url, string $title): string
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '' || !preg_match('#^https?://#i', $url)) {
|
||||
return '';
|
||||
}
|
||||
$binary = self::httpBinary($url, 4 * 1024 * 1024, 12);
|
||||
if ($binary === null || strlen($binary) < 64) {
|
||||
return '';
|
||||
}
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'fbcv');
|
||||
if ($tmp === false) {
|
||||
return '';
|
||||
}
|
||||
if (@file_put_contents($tmp, $binary) === false) {
|
||||
@unlink($tmp);
|
||||
return '';
|
||||
}
|
||||
// 伪装成 $_FILES 的形态交给 Upload::image(它只认 tmp_name/error/name/type)
|
||||
$fake = [
|
||||
'name' => self::safeBase($title) . '.jpg',
|
||||
'type' => 'image/jpeg',
|
||||
'tmp_name' => $tmp,
|
||||
'error' => UPLOAD_ERR_OK,
|
||||
'size' => strlen($binary),
|
||||
];
|
||||
$up = \App\Upload::image($familyId, $fake, 'bookcover', '');
|
||||
@unlink($tmp);
|
||||
return !empty($up['ok']) ? (string)$up['file'] : '';
|
||||
}
|
||||
|
||||
/** 标题 → 可做文件名的安全基名(去掉路径符号,限长) */
|
||||
private static function safeBase(string $s): string
|
||||
{
|
||||
$s = preg_replace('#[\\\\/:*?"<>|\x00-\x1f]#u', '', $s) ?? '';
|
||||
$s = trim(preg_replace('/\s+/u', '_', $s) ?? '');
|
||||
return $s === '' ? 'cover' : mb_substr($s, 0, 60, 'UTF-8');
|
||||
}
|
||||
|
||||
/** 抓一段二进制(3 秒超时不够,封面有时几 MB;失败返回 null,绝不抛) */
|
||||
private static function httpBinary(string $url, int $maxBytes = 3145728, int $timeout = 10): ?string
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
$ctx = stream_context_create(['http' => ['timeout' => $timeout, 'ignore_errors' => true]]);
|
||||
$r = @file_get_contents($url, false, $ctx, 0, $maxBytes);
|
||||
return $r === false ? null : (string)$r;
|
||||
}
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_USERAGENT => 'FamilyBook/1.0',
|
||||
// ⚠️ 微信读书的 CDN 可能挑 Referer —— 给一个自己站点的 Referer
|
||||
CURLOPT_HTTPHEADER => ['Referer: https://weread.qq.com/'],
|
||||
]);
|
||||
$r = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
if ($r === false || $code < 200 || $code >= 300) {
|
||||
return null;
|
||||
}
|
||||
return strlen((string)$r) > $maxBytes ? substr((string)$r, 0, $maxBytes) : (string)$r;
|
||||
}
|
||||
|
||||
/** Unix 时间戳 → 'Y-m-d H:i:s'(0 或非法时给当前时间,别让库里出现 1970) */
|
||||
private static function ts(int $t): string
|
||||
{
|
||||
@@ -409,4 +657,234 @@ final class WeRead
|
||||
$st->execute([$familyId, $t]);
|
||||
return (int)($st->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 定期自动同步(v24 第 25 批 用户第 8 条)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 用户原话:「微信读书导入,需要能够做到定期完成,自动同步。」
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* **为什么不用系统 cron**(这是一个刻意的取舍,写清楚免得下一个人来加 crontab)
|
||||
*
|
||||
* 本项目其它"定期"的事都是**访问时顺手做**(见 StagedUpload::sweep 的注释:
|
||||
* "在 business 路由里顺手调一次,不必挂定时任务")。理由:
|
||||
* ① 这是**家用**系统,可能几周没人访问。挂个 cron 每天凌晨去调微信读书 API,
|
||||
* 在没人用的时候白烧请求、还可能触发官方的频率限制;
|
||||
* ② cron 要配服务器(宝塔面板 + 路径 + PHP 版本),用户自己维护不了,
|
||||
* 而"配错了"是静默的(任务不跑,谁也不知道);
|
||||
* ③ 访问时同步的**体验完全等价**:用户打开读书页 → 数据是新的。
|
||||
* 他不在的时候,同步不同步没有任何人能察觉。
|
||||
*
|
||||
* ⚠️ 所以判据是「**距上次同步过了多久**」而不是"今天跑过没":
|
||||
* 间隔到期才真的去打接口(默认 6 小时,见 SYNC_TTL),
|
||||
* 否则只读一个时间戳。这样"每天开十次书页"也只会同步两次。
|
||||
*
|
||||
* ⚠️ 失败**不重试、不阻塞**:网络不通/Key 失效时静默记一下时间(下次再试),
|
||||
* 绝不让"同步失败"把读书页也打不开 —— 外部依赖不能拖死主流程。
|
||||
*
|
||||
* @param bool $force true = 忽略间隔,立刻同步(用户点「立即同步」时用)
|
||||
* @return array{ok:bool,ran:bool,error:string,books:int,notes:int,covers:int,at:string}
|
||||
*/
|
||||
public static function maybeSync(int $familyId, int $personId, bool $force = false): array
|
||||
{
|
||||
$empty = ['ok' => true, 'ran' => false, 'error' => '', 'books' => 0,
|
||||
'notes' => 0, 'covers' => 0, 'at' => self::lastSync($personId)];
|
||||
if (!self::hasKey($personId) || $familyId <= 0 || $personId <= 0) {
|
||||
return $empty;
|
||||
}
|
||||
$last = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0');
|
||||
if (!$force && $last > 0 && (time() - $last) < self::SYNC_TTL) {
|
||||
return $empty;
|
||||
}
|
||||
// ⚠️ 先记时间再干活:万一下面 20 秒里超时/抛错,也不会在同一个页面
|
||||
// 反复重试("每次打开读书页都卡 20 秒"是最糟的症状)。
|
||||
UserSetting::set($personId, self::SYNC_AT_KEY, (string)time());
|
||||
|
||||
try {
|
||||
$res = self::syncNow($familyId, $personId);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'ran' => true, 'error' => $e->getMessage(),
|
||||
'books' => 0, 'notes' => 0, 'covers' => 0, 'at' => date('Y-m-d H:i:s')];
|
||||
}
|
||||
return [
|
||||
'ok' => true, 'ran' => true, 'error' => (string)($res['error'] ?? ''),
|
||||
'books' => (int)($res['books'] ?? 0), 'notes' => (int)($res['notes'] ?? 0),
|
||||
'covers' => (int)($res['covers'] ?? 0), 'at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 真的同步一遍:把有笔记的书补进书架、补封面、补进度、补笔记。
|
||||
*
|
||||
* 「补」而不是"重建":已经在书架里的书**不覆盖用户改过的东西**
|
||||
* (用户可能改了分类、打了分、改了状态)—— 只补**空着的**那几个字段。
|
||||
* ⚠️ 这是"自动同步"能不能长期开着的关键:只要有一次把用户手动填的
|
||||
* 评分/分类冲掉,他就再也不敢让它自动跑了。
|
||||
*
|
||||
* @return array{ok:bool,error:string,books:int,notes:int,covers:int,scanned:int}
|
||||
*/
|
||||
public static function syncNow(int $familyId, int $personId, int $maxBooks = 60): array
|
||||
{
|
||||
$nb = self::notebooks($personId);
|
||||
if (!$nb['ok']) {
|
||||
return ['ok' => false, 'error' => (string)$nb['error'], 'books' => 0,
|
||||
'notes' => 0, 'covers' => 0, 'scanned' => 0];
|
||||
}
|
||||
$newBooks = $newNotes = $newCovers = 0;
|
||||
$scanned = 0;
|
||||
foreach ($nb['books'] as $b) {
|
||||
if ($scanned >= $maxBooks) {
|
||||
break;
|
||||
}
|
||||
$title = trim((string)$b['title']);
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
$scanned++;
|
||||
$bid = self::matchLocalBook($familyId, $title);
|
||||
|
||||
// ---- ① 书不在书架 → 建一本(顺便把该拿的元数据一次拿齐)----
|
||||
if ($bid <= 0) {
|
||||
$info = self::bookInfo($personId, (string)$b['bookId']);
|
||||
$meta = $info['ok'] ? $info['book'] : [];
|
||||
$coverKey = '';
|
||||
if (!empty($meta['cover'])) {
|
||||
$coverKey = self::fetchCover($familyId, (string)$meta["cover"], $title);
|
||||
if ($coverKey !== '') { $newCovers++; }
|
||||
}
|
||||
$bid = Book::create($familyId, $personId, [
|
||||
'title' => $title,
|
||||
'author' => (string)($meta['author'] ?? $b['author']),
|
||||
'translator' => (string)($meta['translator'] ?? ''),
|
||||
'publisher' => (string)($meta['publisher'] ?? ''),
|
||||
'published' => (string)($meta['published'] ?? ''),
|
||||
'isbn' => (string)($meta['isbn'] ?? ''),
|
||||
'category' => (string)($meta['category'] ?? ''),
|
||||
'summary' => (string)($meta['intro'] ?? ''),
|
||||
'words' => (int)($meta['wordCount'] ?? 0),
|
||||
'rating' => (int)($meta['rating'] ?? 0) ?: null,
|
||||
'cover_key' => $coverKey,
|
||||
'status' => 'reading',
|
||||
]);
|
||||
if ($bid > 0) {
|
||||
$newBooks++;
|
||||
}
|
||||
} else {
|
||||
// ---- ② 已在书架 → **只补空的**(绝不覆盖用户填过的)----
|
||||
$row = Book::find($familyId, $bid);
|
||||
if ($row) {
|
||||
$patch = [];
|
||||
// 封面空着就补(用户第 8 条:封面看不到)
|
||||
if (trim((string)($row['cover_key'] ?? '')) === '') {
|
||||
$info = self::bookInfo($personId, (string)$b['bookId']);
|
||||
if ($info['ok'] && !empty($info['book']['cover'])) {
|
||||
$ck = self::fetchCover($familyId,
|
||||
(string)$info['book']['cover'], $title);
|
||||
if ($ck !== '') { $patch['cover_key'] = $ck; $newCovers++; }
|
||||
}
|
||||
}
|
||||
// 简介/ISBN/出版社/字数空着就补
|
||||
$needMore = trim((string)($row['summary'] ?? '')) === ''
|
||||
|| trim((string)($row['publisher'] ?? '')) === ''
|
||||
|| empty($row['words']);
|
||||
if ($needMore) {
|
||||
$info = self::bookInfo($personId, (string)$b['bookId']);
|
||||
if ($info['ok']) {
|
||||
$m = $info['book'];
|
||||
if (trim((string)($row['summary'] ?? '')) === '' && $m['intro'] !== '') {
|
||||
$patch['summary'] = (string)$m['intro'];
|
||||
}
|
||||
if (trim((string)($row['publisher'] ?? '')) === '' && $m['publisher'] !== '') {
|
||||
$patch['publisher'] = (string)$m['publisher'];
|
||||
}
|
||||
if (trim((string)($row['isbn'] ?? '')) === '' && $m['isbn'] !== '') {
|
||||
$patch['isbn'] = (string)$m['isbn'];
|
||||
}
|
||||
if (empty($row['words']) && (int)$m['wordCount'] > 0) {
|
||||
$patch['words'] = (int)$m['wordCount'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($patch) {
|
||||
self::patchBook($familyId, $bid, $patch);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($bid <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- ③ 进度:读完了就把本地状态也标成读完(用户第 8 条"统计"要它)----
|
||||
$pg = self::progress($personId, (string)$b['bookId']);
|
||||
if ($pg['ok'] && $pg['finished']) {
|
||||
$cur = Book::find($familyId, $bid);
|
||||
if ($cur && (string)$cur['status'] !== 'done') {
|
||||
Book::setStatus($familyId, $bid, 'done');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ④ 笔记(已有的会自动跳过,见 store 的去重判据)----
|
||||
$nt = self::notes($personId, (string)$b['bookId']);
|
||||
if ($nt['ok']) {
|
||||
$r = self::store($bid, $personId, (array)$nt['items']);
|
||||
$newNotes += (int)$r['added'];
|
||||
}
|
||||
}
|
||||
return ['ok' => true, 'error' => '', 'books' => $newBooks, 'notes' => $newNotes,
|
||||
'covers' => $newCovers, 'scanned' => $scanned];
|
||||
}
|
||||
|
||||
/** 只更新**指定几列**(自动同步补空专用;不走 Book::update —— 那会把其它列一起写) */
|
||||
public static function patchBook(int $familyId, int $bookId, array $fields): void
|
||||
{
|
||||
$allow = ['cover_key', 'summary', 'publisher', 'isbn', 'words', 'translator',
|
||||
'published', 'category', 'rating', 'author'];
|
||||
$sets = [];
|
||||
$args = [];
|
||||
foreach ($fields as $k => $v) {
|
||||
if (!in_array($k, $allow, true)) {
|
||||
continue;
|
||||
}
|
||||
$sets[] = "`{$k}` = ?";
|
||||
$args[] = $v === '' ? null : $v;
|
||||
}
|
||||
if (!$sets) {
|
||||
return;
|
||||
}
|
||||
$args[] = $bookId;
|
||||
$args[] = $familyId;
|
||||
Database::get()->prepare(
|
||||
'UPDATE books SET ' . implode(', ', $sets) . ' WHERE id = ? AND family_id = ?'
|
||||
)->execute($args);
|
||||
}
|
||||
|
||||
/** 上次自动同步的时间('Y-m-d H:i:s';从没同步过返回空串) */
|
||||
public static function lastSync(int $personId): string
|
||||
{
|
||||
$t = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0');
|
||||
return $t > 0 ? date('Y-m-d H:i:s', $t) : '';
|
||||
}
|
||||
|
||||
/** 下次该自动同步的时间('Y-m-d H:i:s';没配 Key 返回空串) */
|
||||
public static function nextSync(int $personId): string
|
||||
{
|
||||
if (!self::hasKey($personId)) {
|
||||
return '';
|
||||
}
|
||||
$t = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0');
|
||||
return date('Y-m-d H:i:s', ($t > 0 ? $t : time()) + self::SYNC_TTL);
|
||||
}
|
||||
|
||||
/** 是否打开了自动同步(默认开;用户可在导入页关掉) */
|
||||
public static function autoSyncOn(int $personId): bool
|
||||
{
|
||||
return (string)UserSetting::get($personId, self::AUTO_KEY, '1') === '1';
|
||||
}
|
||||
|
||||
public static function setAutoSync(int $personId, bool $on): void
|
||||
{
|
||||
UserSetting::set($personId, self::AUTO_KEY, $on ? '1' : '0');
|
||||
}
|
||||
}
|
||||
+180
-14
@@ -225,6 +225,21 @@ a:hover { text-decoration: underline; }
|
||||
/* ⚠️ 上面**只写 .card + .card**:.section 自己有 margin-top:28px,
|
||||
若在这里再写 `.card + .section { margin-top:18px }`,特异性 0,2,0 会压过
|
||||
`.section` 的 0,1,0,反而把原本 28px 的间距改小 —— 修 A 不能顺手弄坏 B。 */
|
||||
/* 🔴🔴 v24 第 25 批 用户第 3 条:**并排的两块不该再吃这条"相邻间距"**。
|
||||
用户原话:「日程和任务看板,两个模块,现在有点错位,一个高,一个低,
|
||||
应该完全一样才对。」
|
||||
|
||||
根因(实测,不是猜):`.home-duo` 里左右两块**都是 `.card`**、且在 DOM 里互为
|
||||
紧邻兄弟 → `.card + .card` 命中**第二块** → 给它加了 `margin-top:18px`。
|
||||
grid 的 `align-items: stretch` 让两块**高度**相等(那个数是对的),
|
||||
但第二块被整体推下去 18px —— 于是"一样高、却一高一低"。
|
||||
(我上一版只调了 `.home-duo` 的 align-items 与高度,没动这条,
|
||||
真机探针才把 18px 这个偏移量量出来 —— 光看代码是看不出来的。)
|
||||
|
||||
⇒ 并排容器里的 `.card` 一律不吃相邻间距(间距由 `gap` 统一给)。
|
||||
用 `:first-child` 之外的兄弟选择器把它们点掉,且**特异性要比 0,2,0 高**。 */
|
||||
.home-duo > .card { margin-top: 0; }
|
||||
.home-duo > .card + .card { margin-top: 0; }
|
||||
|
||||
/* 首页头图 */
|
||||
.hero {
|
||||
@@ -1243,14 +1258,21 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
||||
border-radius: 6px; font-size: 14px;
|
||||
color: var(--muted); background: rgba(255, 255, 255, .82);
|
||||
border: 1px solid var(--line-2);
|
||||
/* ⚠️ 手柄现在是**能抓的**(不再 pointer-events:none):
|
||||
鼠标可以拖整行,但**触摸只能抓这个手柄** —— 否则手指在行上一划
|
||||
就被浏览器当成滚页面,永远拖不起来。touch-action:none 就是这句话。 */
|
||||
/* 🔴 v24 第 25 批 用户第 5 条:手柄是**拖动名册的唯一入口**。
|
||||
用户原话:「点击列表中的人,是直接查看这个人的信息,拖动的时候才是调整数序。」
|
||||
→ 整行不再参与拖动,**只有按在这个 ⠿ 上才算**(鼠标与触摸同一判据)。
|
||||
所以它必须一眼看得出来"这里是能抓的":
|
||||
· `cursor: grab`(抓着时 JS 加 .is-dragged 的卡片会变 grabbing,见下)
|
||||
· `touch-action: none` —— 不写它,手指在柄上一划会被浏览器当成滚页面,永远拖不起来
|
||||
· 悬停加一点底色,别让它看着像一个装饰性的字符 */
|
||||
touch-action: none; cursor: grab;
|
||||
}
|
||||
.drag-handle:hover { background: var(--brand-soft, rgba(168,74,40,.10)); color: var(--brand); }
|
||||
.drag-handle-inline { color: var(--muted); }
|
||||
/* 列表里每行要留出左侧拖柄的位置(否则⠿会压在头像上) */
|
||||
.roster.is-droppable .person { padding-left: 18px; }
|
||||
/* 正被拖的那张卡:手柄变抓握态(与 cursor:grab 配成一对) */
|
||||
.person.is-dragged .drag-handle { cursor: grabbing; }
|
||||
|
||||
/* 空栏平时收起来(七个预置分组可能只用到三个,摊开会显得名册破破烂烂)——
|
||||
但**拖动时必须露出来**,否则"想拖到一个现在没人的分组里"就没地方放。 */
|
||||
@@ -1904,6 +1926,24 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
||||
}
|
||||
.ann-date { flex: 0 0 auto; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.ann-title { flex: 1 1 auto; color: var(--ink); min-width: 0; }
|
||||
/* ---- 纪念日的「关联人」列(v24 第 25 批 用户第 2 条)----
|
||||
用户原话:「再加一列,关联人。列表中,人的前面,都加头像。」
|
||||
|
||||
⚠️ 列里画的是全站统一的 person_chip(`.viewer-tag`,自带头像 + 底色),
|
||||
所以这里只需要处理**排布**,不要另造一套人名样式(会与其它模块不一致)。
|
||||
⚠️ 关联多个人时要能换行 —— `.viewer-tag` 是 `inline-flex`,
|
||||
不写 flex-wrap 的话 5 个人会把表格撑宽(手机上直接横向溢出)。
|
||||
⚠️ 「改/删」两个按钮要排成一行:`.res-inline` 那个 form 是 inline 的,
|
||||
但两个按钮之间得靠 gap 分开,否则会贴在一起、看着像一个双色按钮。 */
|
||||
.ann-people { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; max-width: 260px; }
|
||||
.ann-acts { display: flex; gap: 6px; align-items: center; }
|
||||
.ann-acts .btn { flex: 0 0 auto; }
|
||||
@media (max-width: 767px) {
|
||||
/* 窄屏:关联人那一列不再限宽(表格本来就会横向滚动),
|
||||
但两个按钮要保持不换行 —— 换行后「删除」会掉到「修改」下面,
|
||||
在表格行里看着像两张卡片。 */
|
||||
.ann-people { max-width: none; }
|
||||
}
|
||||
.chk-inline { display: flex; align-items: center; gap: 6px; font-size: 14px; }
|
||||
/* ---- 任务设置(v24 第 9、10 条):看板/分类的增删改列表 ---- */
|
||||
.kb-boards { margin-bottom: 12px; }
|
||||
@@ -2609,6 +2649,13 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
||||
}
|
||||
.kb-card.is-dragging { opacity: .45; }
|
||||
.kb-card.is-overdue { border-color: rgba(192, 83, 63, .4); }
|
||||
/* 🔴 v24 第 25 批 用户第 6 条:卡片加了「完成」复选框之后,它必须与标题**同一行**。
|
||||
作者样式是 `flex-direction: column` —— 不处理的话复选框会独占一行、
|
||||
标题被挤到第二行,一张卡凭空高一截(用户要的是"任务前面带一个复选框",
|
||||
不是"任务上面带一个")。做法:把标题包成一行(.kb-top),
|
||||
复选框 + 标题一起横排,剩下的 meta/foot 仍然竖着叠。 */
|
||||
.kb-top { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.kb-top .kb-title { flex: 1 1 auto; min-width: 0; }
|
||||
.kb-title { font-size: 14px; font-weight: 500; color: var(--ink); }
|
||||
.kb-title:hover { color: var(--brand); }
|
||||
.kb-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; font-size: 12px; color: var(--muted); }
|
||||
@@ -2647,11 +2694,9 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
||||
border: 1px solid var(--line-2); border-radius: var(--radius-s); background: #FDFBF7;
|
||||
}
|
||||
.sub-row.is-done .sub-title { color: var(--muted); text-decoration: line-through; }
|
||||
.sub-check {
|
||||
background: none; border: 0; cursor: pointer; font-size: 17px; line-height: 1;
|
||||
padding: 0 2px; color: var(--brand);
|
||||
}
|
||||
.sub-check.is-static { cursor: default; color: var(--muted); }
|
||||
/* ⚠️ `.sub-check`(旧的 ☐/☑ 字符按钮)已在 v24 第 25 批 用户第 6 条**删掉**:
|
||||
子任务改用全站统一的 `.tk-check`(见上面那一段)。留一份没人用的样式
|
||||
只会让下一个人以为还有第二条画法。 */
|
||||
.sub-title { flex: 1 1 auto; min-width: 0; font-size: 14px; }
|
||||
.cmt-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.cmt-row { border-top: 1px solid var(--line-2); padding-top: 10px; }
|
||||
@@ -4118,13 +4163,32 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
||||
/* ---- ② 日程 / 任务 左右各半 ----
|
||||
⚠️ 高度**固定**(用户原话「为了好看高度需要固定」),滚动条**藏起来**
|
||||
(「鼠标放上去之后向下可以滚动,暂时不要出现滚动条太丑了」)。
|
||||
藏滚动条要三套写法(Firefox / IE-Edge / WebKit 各一套),少一套就在某个浏览器上露出来。 */
|
||||
.home-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; }
|
||||
.hd-pane { display: flex; flex-direction: column; }
|
||||
.hd-pane-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
藏滚动条要三套写法(Firefox / IE-Edge / WebKit 各一套),少一套就在某个浏览器上露出来。
|
||||
|
||||
🔴🔴 v24 第 25 批 用户第 3 条修的真 bug:「日程和任务看板,两个模块,
|
||||
现在有点错位,一个高,一个低,应该完全一样才对。」
|
||||
|
||||
根因:这两块原来是 `align-items: start` + 各自内容撑高 ——
|
||||
左边日程的**刻度行数**由"今天有几件事"决定(掐头去尾之后 3~9 行不等),
|
||||
右边的任务条数也各不相同 ⇒ 两张卡片高度天然不等,一眼就是"一高一低"。
|
||||
|
||||
修法(三步,缺一不可):
|
||||
① `align-items: stretch` —— 让两栏拉成同一行高(grid 的默认行为,
|
||||
但上一版显式写成了 `start`,那等于主动放弃对齐);
|
||||
② `.hd-pane` 给一个**固定总高**并 `display:flex; flex-direction:column`
|
||||
(它本来就有 flex 列),head 不收缩、body 吃满剩余;
|
||||
③ `.hd-pane-body` 用 `flex: 1 1 auto` + `min-height: 0`
|
||||
取代原来的固定 `height: 360px`。
|
||||
⚠️ `min-height: 0` 不能省:flex 子项的默认 `min-height:auto`
|
||||
会让它**不肯缩到内容以下**,于是内容一多就把卡片顶高、
|
||||
对齐又失效(这是"固定高度 + 内部滚动"这套写法的经典坑)。
|
||||
这样两块的**外框高度恒等**,与各自内容多少无关 —— 对齐不再依赖数据。 */
|
||||
.home-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: stretch; }
|
||||
.hd-pane { display: flex; flex-direction: column; height: 440px; min-height: 0; }
|
||||
.hd-pane-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex: 0 0 auto; }
|
||||
.hd-pane-head .cinema-h2 { margin: 0; flex: 1 1 auto; }
|
||||
.hd-pane-body {
|
||||
height: 360px; overflow-y: auto; overscroll-behavior: contain;
|
||||
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* 旧 Edge / IE */
|
||||
}
|
||||
@@ -4172,10 +4236,60 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
||||
}
|
||||
.tk-due { flex: 0 0 auto; font-size: 11.5px; color: var(--faint); }
|
||||
|
||||
/* ---- 任务的「完成」复选框(v24 第 25 批 用户第 6 条)----
|
||||
用户原话:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框,
|
||||
这个任务就完成了。」
|
||||
|
||||
⚠️ 它是 `<button>` 不是 `<input type=checkbox>`(见 _task_check.php 的说明):
|
||||
勾没勾**由服务端状态渲染的 class** 说了算,不是浏览器自己管 checked。
|
||||
⚠️ 尺寸写死 18px 且 `flex: 0 0 auto`:卡片标题是长文本,
|
||||
不写死的话它会被 flex 压扁(同一行里"文字优先")。
|
||||
⚠️ 勾选态用**伪元素画对勾**而不是塞一个 ✓ 字符:
|
||||
字符在不同字体里基线不同,18px 的方块里会歪。 */
|
||||
.tk-check {
|
||||
flex: 0 0 auto; width: 18px; height: 18px; padding: 0; margin: 0;
|
||||
border: 1.5px solid var(--line); border-radius: 5px; background: #fff;
|
||||
cursor: pointer; position: relative; transition: border-color .15s, background .15s;
|
||||
}
|
||||
.tk-check:hover { border-color: var(--brand); }
|
||||
.tk-check::after {
|
||||
content: ""; position: absolute; left: 5px; top: 1.5px;
|
||||
width: 5px; height: 10px; border: solid #fff; border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg) scale(0); transition: transform .13s ease;
|
||||
}
|
||||
.tk-check.is-done { background: var(--ok, #5E9E4E); border-color: var(--ok, #5E9E4E); }
|
||||
.tk-check.is-done::after { transform: rotate(45deg) scale(1); }
|
||||
/* 只读账号:一颗不可点的静态方块 */
|
||||
.tk-check.is-static { cursor: default; }
|
||||
.tk-check.is-static:hover { border-color: var(--line); }
|
||||
/* 已完成的那张卡:标题压灰划掉(一眼看出"这件做完了") */
|
||||
.kb-card.is-done .kb-title { color: var(--muted); text-decoration: line-through; }
|
||||
/* ⚠️ 首页/详情那种"一行文字 + 一颗复选框"的场合:
|
||||
`.dash-row` 是 `align-items: baseline`,而复选框是 18px 的方块 ——
|
||||
按基线对齐会让它**沉到文字下面半截**(基线是文字的底边,方块没有基线)。
|
||||
所以带 .tk-check 的行改成 `center`,文字与方块的**中线**对齐,
|
||||
这才是"复选框在文字前面"该有的样子。
|
||||
(`.sub-row` 本来就是 center,不用管它。) */
|
||||
.dash-row:has(.tk-check) { align-items: center; }
|
||||
/* 详情页标题前那颗:标题是 22px 大字,复选框要跟着走中线才不悬空 */
|
||||
.page-head h1:has(> .tk-check) { display: inline-flex; align-items: center; gap: 10px; }
|
||||
|
||||
/* 看板页顶上的「三个状态到底什么意思」(用户第 6 条直接问出来的那句话) */
|
||||
.tk-legend {
|
||||
display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: center;
|
||||
margin: 0 0 12px; padding: 9px 12px;
|
||||
background: var(--surface-2, rgba(0, 0, 0, .02));
|
||||
border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||
}
|
||||
.tk-legend .tk-lg-item { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; }
|
||||
.tk-legend .tk-lg-hint { color: var(--muted); }
|
||||
|
||||
/* ---- 窄屏:并排改成上下(各占一半宽度在手机上没法读)---- */
|
||||
@media (max-width: 760px) {
|
||||
.home-duo { grid-template-columns: 1fr; }
|
||||
.hd-pane-body { height: 300px; }
|
||||
/* ⚠️ 窄屏仍要保持**固定高度**(用户要的是"高度固定、内部滚动"),
|
||||
只是比桌面矮一点。不写成 auto:那样内容一多整页就长得没法用。 */
|
||||
.hd-pane { height: 320px; }
|
||||
.hb-weather { margin-left: 0; padding-left: 0; border-left: 0; padding-top: 10px; border-top: 1px solid var(--line-2); width: 100%; }
|
||||
}
|
||||
|
||||
@@ -4235,3 +4349,55 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
||||
.wr-q i { font-style: normal; color: var(--ink); }
|
||||
.wr-q em { font-style: normal; color: var(--brand-ink); }
|
||||
.wr-q em::before { content: '💭 '; }
|
||||
|
||||
/* ---- 微信读书:封面(v24 第 25 批 用户第 8 条「封面图看不到」)----
|
||||
⚠️ 尺寸必须写死(52×74 ≈ 2:3,与 bookcover 槽位的裁剪比例一致):
|
||||
不写死的话,官方封面各尺寸不一,一行里高低参差(这正是"看起来乱"的原因)。
|
||||
⚠️ `object-fit: cover` + 固定高宽:封面比例不一时居中裁,不拉伸变形。 */
|
||||
.wr-cv {
|
||||
flex: 0 0 auto; width: 52px; height: 74px; border-radius: 6px;
|
||||
object-fit: cover; background: var(--line-2);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .10);
|
||||
}
|
||||
.wr-cv-ph {
|
||||
display: flex; align-items: center; justify-content: center; font-size: 22px;
|
||||
color: var(--faint); box-shadow: none; border: 1px dashed var(--line);
|
||||
}
|
||||
/* 外链封面(统计块里的「读得最多的书」,直接来自官方 CDN,本站不下载) */
|
||||
.wr-top-cv {
|
||||
flex: 0 0 auto; width: 34px; height: 48px; border-radius: 5px;
|
||||
object-fit: cover; background: var(--line-2);
|
||||
}
|
||||
.wr-top-ph { display: flex; align-items: center; justify-content: center; color: var(--faint); }
|
||||
|
||||
/* ---- 自动同步(用户第 8 条)---- */
|
||||
.wr-sync { display: flex; flex-wrap: wrap; gap: 8px 22px; font-size: 13px; }
|
||||
.wr-sync-item { color: var(--muted); }
|
||||
.wr-sync-item b { color: var(--ink); font-weight: 600; }
|
||||
|
||||
/* ---- 阅读统计(用户第 8 条)----
|
||||
一排数字卡。⚠️ `flex: 0 1 auto` + `min-width` 而不是等宽:
|
||||
指标个数(时长/天数/日均/读过/读完/笔记)随官方返回而变,
|
||||
写死等宽会在某个指标缺失时留一块空。 */
|
||||
.wr-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 2px 0 4px; }
|
||||
.wr-stat {
|
||||
flex: 0 1 auto; min-width: 108px; padding: 10px 14px;
|
||||
background: var(--line-2); border-radius: var(--radius-s);
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.wr-stat b { font-size: 17px; color: var(--ink); font-weight: 600; }
|
||||
.wr-stat span { font-size: 12px; color: var(--muted); }
|
||||
.wr-sub { margin: 18px 0 8px; font-size: 14px; font-weight: 600; color: var(--ink); }
|
||||
/* 读得最多的书 */
|
||||
.wr-top { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.wr-top-item { display: flex; align-items: center; gap: 10px; }
|
||||
.wr-top-b { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
.wr-top-t { font-size: 13.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wr-top-time { flex: 0 0 auto; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
/* 偏好分类:名称 + 条 + 数值 */
|
||||
.wr-cats { display: flex; flex-direction: column; gap: 6px; }
|
||||
.wr-cat { display: flex; align-items: center; gap: 10px; font-size: 13px; }
|
||||
.wr-cat-n { flex: 0 0 84px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wr-cat-bar { flex: 1 1 auto; height: 7px; background: var(--line-2); border-radius: 999px; overflow: hidden; }
|
||||
.wr-cat-bar i { display: block; height: 100%; background: var(--brand); border-radius: 999px; }
|
||||
.wr-cat-v { flex: 0 0 auto; font-size: 12px; }
|
||||
|
||||
+33
-42
@@ -839,33 +839,14 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次按下该不该算"拖动"?
|
||||
* v24 第 25 批 用户第 5 条:拖拽的起手判据只剩一条 —— **必须按在 ⠿ 手柄上**。
|
||||
*
|
||||
* ⚠️ 按钮 / 输入框 / 下拉 / <summary> 一律不算 —— 在那儿按下是"要用它",
|
||||
* 不是"要拖这张卡"。唯一例外是 `.drag-handle`(它是专门用来抓的)。
|
||||
* ⚠️ 大链接 `.person-main` **算**:整行都能拖才顺手(这也是原来的手感),
|
||||
* 靠 MIN_MOVE 门槛区分"点进去看详情"和"拖着走",并且拖过之后要掐掉那次 click。
|
||||
* 原来的 `isControl()`(判断"这一点是不是按钮/输入框/大链接")**已删除**:
|
||||
* 那个函数存在的前提是"整行都能拖",而用户明确要求改成
|
||||
* 「点击列表中的人,是直接查看这个人的信息,拖动的时候才是调整数序」。
|
||||
* 整行不再参与拖动之后,"哪些控件不该触发拖拽"这个问题就不存在了 ——
|
||||
* 留着一个没人调用的函数只会让下一个人以为还有第二条拖拽路径。
|
||||
*/
|
||||
function isControl(node) {
|
||||
var t = node;
|
||||
while (t && t !== roster) {
|
||||
if (t.nodeType !== 1) { t = t.parentNode; continue; }
|
||||
// ⚠️ 判定顺序不能乱:先认"专门用来抓的"和"整行那个大链接",
|
||||
// 再认"点了就有别的作用的控件"。
|
||||
if (t.classList && t.classList.contains('drag-handle')) { return false; }
|
||||
// `.person-main` 虽然是 <a>(点它会进详情页),但整行都能拖才顺手 ——
|
||||
// 靠 MIN_MOVE 区分"点"与"拖",拖过之后掐掉那次 click。
|
||||
if (t.classList && t.classList.contains('person-main')) { return false; }
|
||||
if (t.getAttribute && t.getAttribute('data-no-drag') !== null) { return true; }
|
||||
var tag = t.tagName;
|
||||
if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|
||||
|| tag === 'SUMMARY' || tag === 'LABEL' || tag === 'A') {
|
||||
return true;
|
||||
}
|
||||
t = t.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 每栏人数与"空栏"标记跟着 DOM 走 —— 不然拖完之后标题上的「N 人」还是旧的 */
|
||||
function refreshCounts() {
|
||||
@@ -995,6 +976,8 @@
|
||||
|
||||
function beginDrag(card, pt) {
|
||||
dragging = card;
|
||||
// ⚠️ v24 第 25 批:`is-dragging` 从 pointerdown 挪到了这里(见 pointerdown 的说明)。
|
||||
// 只有从 ⠿ 手柄起拖,所以"提前露空栏"不再必要 —— 真开始拖时再露。
|
||||
roster.classList.add('is-dragging');
|
||||
card.classList.add('is-dragged');
|
||||
placeAt(pt);
|
||||
@@ -1020,22 +1003,31 @@
|
||||
if (e.button !== 0 && e.pointerType === 'mouse') { return; }
|
||||
var card = cardFrom(e.target);
|
||||
if (!card) { return; }
|
||||
// 触摸时整行都能拖会让页面没法上下滚 —— 触摸只认那个 ⠿ 手柄
|
||||
/* 🔴🔴 v24 第 25 批 用户第 5 条:**只有从 ⠿ 手柄上按下才算拖动**。
|
||||
*
|
||||
* 用户原话:「现在名册的操作还是很不好,应该是点击列表中的人,是直接查看这个人的信息,
|
||||
* 拖动的时候才是调整数序,现在是混乱的。」
|
||||
*
|
||||
* 改之前的行为(三处叠加,所以"混乱"):
|
||||
* ① 鼠标在整行任何非控件处按下都算"待拖";
|
||||
* ② 而且**按下那一刻**就给 .roster 加 `is-dragging`(本来是为了提前露出空栏当落点);
|
||||
* ③ 于是轻轻点一下卡片,空栏先"冒出来"、页面高度跳一下,视觉上就是"我点了一下,
|
||||
* 页面自己动了一格";抬起时又收回去 —— 用户根本分不清自己是在点还是在拖。
|
||||
* ⇒ 现在:**整行都不再触发拖拽**;只有那枚 ⠿(.drag-handle,专门用来抓的)
|
||||
* 按下才进入待拖状态。点击整行 = 沿 .person-main 链接进详情页(原样保留)。
|
||||
*
|
||||
* ⚠️ 鼠标与触摸**同一判据**(不再按 pointerType 分叉):
|
||||
* 以前触摸只认手柄、鼠标认整行 —— 两种设备手感不同,用户换台机器就"又变了"。
|
||||
* 统一成"都只认手柄"之后,语义只有一条:**拖这个 ⠿**。
|
||||
* ⚠️ `is-dragging` 也挪到 beginDrag() 里加(不再在 pointerdown 加):
|
||||
* 既然只有手柄能起拖,就没有"提前露空栏"的必要了 ——
|
||||
* 手柄本身很小,用户按下它就是明确要拖,那一刻再露空栏不影响瞄准。 */
|
||||
var fromHandle = !!(e.target.closest && e.target.closest('.drag-handle'));
|
||||
if (e.pointerType !== 'mouse' && !fromHandle) { return; }
|
||||
if (!fromHandle && isControl(e.target)) { return; }
|
||||
if (!fromHandle) { return; }
|
||||
pending = { card: card, id: e.pointerId, type: e.pointerType };
|
||||
startPt = { x: e.clientX, y: e.clientY };
|
||||
lastPt = { x: e.clientX, y: e.clientY };
|
||||
/* 🔴 空栏必须在**按下的这一刻**就露出来,不能等"真的开始拖"才露。
|
||||
露出来会让页面变高、下面的分组整体下移 ——
|
||||
如果等到拖动开始才露,用户瞄准的那个位置在手指一动之后就跑了
|
||||
(症状:想拖到「未分类」,松手却落进了别的栏,或者干脆没动静)。
|
||||
按下时就露,布局在**瞄准之前**已经稳定,之后全程不变。
|
||||
⚠️ 只点一下不拖的情况:松手时会收回去(见下面的 pointerup);
|
||||
而"点一下"多半是点这一行看详情,页面本来就要跳走,看不到这一下。 */
|
||||
roster.classList.add('is-dragging');
|
||||
if (fromHandle) { e.preventDefault(); } // 别顺手选中文字
|
||||
e.preventDefault(); // 别顺手选中文字
|
||||
});
|
||||
|
||||
document.addEventListener('pointermove', function (e) {
|
||||
@@ -1054,16 +1046,15 @@
|
||||
document.addEventListener('pointerup', function (e) {
|
||||
if (dragging) { endDrag(); }
|
||||
else if (pending && e.pointerId === pending.id) {
|
||||
// 在手柄上按下、但位移没到门槛(= 没真拖)→ 清掉待拖态,什么都不做。
|
||||
// ⚠️ v24 第 25 批:这里**不再**需要 `is-dragging` 的收回 ——
|
||||
// 那个类现在只在 beginDrag() 里加(见 pointerdown 的说明)。
|
||||
pending = null;
|
||||
roster.classList.remove('is-dragging'); // 只是点了一下:把露出来的空栏收回去
|
||||
}
|
||||
});
|
||||
document.addEventListener('pointercancel', function () {
|
||||
if (dragging) { endDrag(); }
|
||||
else if (pending) {
|
||||
pending = null;
|
||||
roster.classList.remove('is-dragging');
|
||||
}
|
||||
else if (pending) { pending = null; }
|
||||
});
|
||||
|
||||
// 拖完掐掉那一次 click(捕获阶段就拦,别等它冒泡到链接上)
|
||||
|
||||
@@ -961,7 +961,18 @@ switch ($r) {
|
||||
case 'people/group':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
// v24 第 25 批 用户第 1 条:「名册模块在列表中,对列表中的某一个人进行分组,
|
||||
// 也不应该刷新界面,而是应该动态调整页面。」
|
||||
// → 与 people/reorder 同一套做法:**AJAX 请求回 JSON**(前端就地挪卡片),
|
||||
// 普通表单提交仍走 302 兜底(没有 JS 时照样能用)。
|
||||
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
|
||||
if (!Auth::canWrite()) {
|
||||
if ($isAjax) {
|
||||
header('Content-Type: application/json; charset=utf-8', true, 403);
|
||||
echo json_encode(['ok' => false, 'error' => 'readonly',
|
||||
'msg' => '你的身份是访客,不能改名册里的分组。'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('err', '你的身份是访客,不能改名册里的分组。');
|
||||
redirect('people');
|
||||
}
|
||||
@@ -970,16 +981,42 @@ switch ($r) {
|
||||
$gid = (int)($_POST['group_id'] ?? 0);
|
||||
$p = Person::find($pid);
|
||||
if (!$p || (int)$p['family_id'] !== $familyId) {
|
||||
if ($isAjax) {
|
||||
header('Content-Type: application/json; charset=utf-8', true, 404);
|
||||
echo json_encode(['ok' => false, 'msg' => '找不到这个人。'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('err', '找不到这个人。');
|
||||
redirect('people');
|
||||
}
|
||||
if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) {
|
||||
if ($isAjax) {
|
||||
header('Content-Type: application/json; charset=utf-8', true, 200);
|
||||
echo json_encode(['ok' => false,
|
||||
'msg' => '这个人不在你的名册里,或者那个分组你用不了。'],
|
||||
JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('err', '这个人不在你的名册里,或者那个分组你用不了。');
|
||||
redirect('people');
|
||||
}
|
||||
flash('ok', $gid > 0
|
||||
? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。'
|
||||
: '已把「' . $p['name'] . '」改成未分类。');
|
||||
$gname = $gid > 0 ? PersonGroup::name($familyId, $gid) : '';
|
||||
$msg = $gid > 0
|
||||
? '已把「' . $p['name'] . '」归到「' . $gname . '」。'
|
||||
: '已把「' . $p['name'] . '」改成未分类。';
|
||||
if ($isAjax) {
|
||||
// ⚠️ 前端需要**分组名**去改那颗按钮上的文字("改成未分类"时是空串)
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'pid' => $pid,
|
||||
'gid' => $gid,
|
||||
'gname' => $gname,
|
||||
'msg' => $msg,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('ok', $msg);
|
||||
redirect('people');
|
||||
break;
|
||||
|
||||
@@ -3718,6 +3755,13 @@ switch ($r) {
|
||||
$me = (int)Auth::personId();
|
||||
// 用户 2026-10-03 第 9 条:手动添加的与模块派生的**分开**,手动的在上。
|
||||
$sp = Anniversary::split($familyId, $me);
|
||||
$people = Person::roster($familyId, $me, 'member', '');
|
||||
// v24 第 25 批 用户第 2 条:「再加一列,关联人。列表中,人的前面,都加头像。」
|
||||
// 模板按 id 取头像与称呼 → 这里先做成 id => 行 的映射(不必在模板里循环找人)。
|
||||
$pmap = [];
|
||||
foreach ($people as $pp) {
|
||||
$pmap[(int)$pp['id']] = $pp;
|
||||
}
|
||||
View::render('days/index', [
|
||||
'title' => '纪念日',
|
||||
'custom' => $sp['custom'],
|
||||
@@ -3725,7 +3769,8 @@ switch ($r) {
|
||||
'upcoming' => Anniversary::upcoming($familyId, $me, 60),
|
||||
'kinds' => Anniversary::kinds($familyId),
|
||||
'kindCounts' => Anniversary::kindCounts($familyId),
|
||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||
'people' => $people,
|
||||
'peopleMap' => $pmap,
|
||||
], 'days');
|
||||
break;
|
||||
|
||||
@@ -3778,6 +3823,12 @@ switch ($r) {
|
||||
flash('err', '找不到这条纪念日。');
|
||||
redirect('days');
|
||||
}
|
||||
// v24 第 25 批 用户第 2 条:编辑页要把**已关联的多个人**预勾选出来。
|
||||
// 新表为空时回落到老列 person_id(升级过渡期的兜底)。
|
||||
if ($editing) {
|
||||
$pids = Anniversary::peopleOf($editing);
|
||||
$editing['person_ids'] = $pids;
|
||||
}
|
||||
View::render('days/form', [
|
||||
'title' => $id > 0 ? '编辑纪念日' : '新建纪念日',
|
||||
'editing' => $editing,
|
||||
@@ -3791,6 +3842,12 @@ switch ($r) {
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
// v24 第 25 批 用户第 2 条:关联人改成**多值** `person_ids[]`。
|
||||
// ⚠️ 兼容老表单:只传单个 `person_id` 时也认(见 Anniversary::save 的说明)。
|
||||
$pids = (array)($_POST['person_ids'] ?? []);
|
||||
if (!$pids && isset($_POST['person_id'])) {
|
||||
$pids = [(int)$_POST['person_id']];
|
||||
}
|
||||
Anniversary::save($familyId, [
|
||||
'title' => (string)($_POST['title'] ?? ''),
|
||||
'repeat_type' => (string)($_POST['repeat_type'] ?? 'solar'),
|
||||
@@ -3799,7 +3856,7 @@ switch ($r) {
|
||||
'is_leap' => !empty($_POST['is_leap']),
|
||||
'kind' => (string)($_POST['kind'] ?? ''),
|
||||
'note' => (string)($_POST['note'] ?? ''),
|
||||
'person_id' => (int)($_POST['person_id'] ?? 0),
|
||||
'person_ids' => array_map('intval', $pids),
|
||||
], $id);
|
||||
flash('ok', $id > 0 ? '纪念日已保存。' : '纪念日已添加。');
|
||||
redirect('days');
|
||||
@@ -5026,16 +5083,36 @@ switch ($r) {
|
||||
$status = (string)($_POST['status'] ?? '');
|
||||
$t = Task::find($familyId, $id, $me);
|
||||
if (!$t || !Task::canWrite($t, $me)) {
|
||||
// v24 第 25 批 用户第 6 条:复选框在**任何地方**都能点(首页/看板/详情),
|
||||
// 所以这三处都可能发来 AJAX。失败也回 JSON,别让它落进"整页跳转"。
|
||||
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
|
||||
header('Content-Type: application/json; charset=utf-8', true, 403);
|
||||
echo json_encode(['ok' => false, 'msg' => '这项任务不能改。'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('err', '这项任务不能改。');
|
||||
redirect('tasks');
|
||||
}
|
||||
Task::setStatus($familyId, $id, $status);
|
||||
$word = Task::statusWord(Task::normalizeStatus($status));
|
||||
if ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '') {
|
||||
flash('ok', '已标成「' . $word . '」,下一次的也排上了。');
|
||||
} else {
|
||||
flash('ok', '已标成「' . $word . '」。');
|
||||
$msg = ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '')
|
||||
? '已标成「' . $word . '」,下一次的也排上了。'
|
||||
: '已标成「' . $word . '」。';
|
||||
// v24 第 25 批:勾复选框 / 点状态徽标都走这条 ——
|
||||
// 带 X-Requested-With 时回 JSON(前端就地改那张卡,不刷页);
|
||||
// 普通表单提交照旧 302(没有 JS 时的兜底)。
|
||||
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'id' => $id,
|
||||
'status' => Task::normalizeStatus($status),
|
||||
'word' => $word,
|
||||
'msg' => $msg,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('ok', $msg);
|
||||
redirect((string)($_POST['back'] ?? '') === 'show' ? 'tasks/show' : 'tasks',
|
||||
(string)($_POST['back'] ?? '') === 'show' ? ['id' => $id] : []);
|
||||
break;
|
||||
@@ -5166,6 +5243,17 @@ switch ($r) {
|
||||
case 'books':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
// ---- v24 第 25 批 用户第 8 条:「需要能够做到定期完成,自动同步」----
|
||||
//
|
||||
// ⚠️ 触发点放在**读书列表页**(而不只是"微信读书导入"那一页):
|
||||
// 用户日常打开的是「读书」这个模块入口,导入页他一个月也去不了一次。
|
||||
// 挂在导入页 = 自动同步基本永远不会被触发(等于没做)。
|
||||
// ⚠️ 代价可忽略:maybeSync 内部先看间隔(6 小时),绝大多数请求只是
|
||||
// 读一个 user_settings 时间戳,不联网。
|
||||
// ⚠️ 失败静默(只记时间戳,下次再试)—— 外部依赖不能把书架页也拖慢/打挂。
|
||||
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) {
|
||||
WeRead::maybeSync($familyId, $me, false);
|
||||
}
|
||||
$f = [
|
||||
'status' => (string)($_GET['status'] ?? ''),
|
||||
'category' => (string)($_GET['cat'] ?? ''),
|
||||
@@ -5535,6 +5623,25 @@ switch ($r) {
|
||||
: (string)$r['error']);
|
||||
redirect('books/weread');
|
||||
}
|
||||
// v24 第 25 批 用户第 8 条:自动同步的总开关(默认开)
|
||||
if ($act === 'auto_toggle') {
|
||||
WeRead::setAutoSync($me, !WeRead::autoSyncOn($me));
|
||||
flash('ok', WeRead::autoSyncOn($me)
|
||||
? '已打开自动同步(打开读书页时顺带同步,约每 6 小时一次)。'
|
||||
: '已关闭自动同步,需要手动点「立即同步」。');
|
||||
redirect('books/weread');
|
||||
}
|
||||
// 用户第 8 条:手动「立即同步」(忽略间隔,立刻打一次接口)
|
||||
if ($act === 'sync_now') {
|
||||
$r = WeRead::maybeSync((int)Auth::familyId(), $me, true);
|
||||
if (!$r['ok']) {
|
||||
flash('err', '同步失败:' . (string)$r['error']);
|
||||
} else {
|
||||
flash('ok', '同步完成:新增 ' . (int)$r['books'] . ' 本书、'
|
||||
. (int)$r['covers'] . ' 张封面、' . (int)$r['notes'] . ' 条笔记。');
|
||||
}
|
||||
redirect('books/weread');
|
||||
}
|
||||
if ($act === 'fetch') {
|
||||
// 「拉取预览」:只取数据、**不落库**,让用户先看一眼再决定
|
||||
$nb = WeRead::notebooks($me);
|
||||
@@ -5545,10 +5652,14 @@ switch ($r) {
|
||||
$preview = [];
|
||||
foreach (array_slice($nb['books'], 0, 40) as $b) {
|
||||
$nt = WeRead::notes($me, (string)$b['bookId']);
|
||||
// v24 第 25 批 用户第 8 条:预览里也把**封面**带出来,
|
||||
// 让用户选书时看得见是哪一本(原来只有一行文字)。
|
||||
$info = WeRead::bookInfo($me, (string)$b['bookId']);
|
||||
$preview[] = [
|
||||
'title' => (string)$b['title'],
|
||||
'author' => (string)$b['author'],
|
||||
'bookId' => (string)$b['bookId'],
|
||||
'cover' => $info['ok'] ? (string)($info['book']['cover'] ?? '') : '',
|
||||
'count' => count((array)($nt['items'] ?? [])),
|
||||
'items' => array_slice((array)($nt['items'] ?? []), 0, 3),
|
||||
'local' => WeRead::matchLocalBook((int)Auth::familyId(), (string)$b['title']),
|
||||
@@ -5558,19 +5669,31 @@ switch ($r) {
|
||||
'title' => '微信读书导入',
|
||||
'preview' => $preview,
|
||||
'nbCount' => count($nb['books']),
|
||||
'stats' => WeRead::readStats($me, 'overall'),
|
||||
], 'books');
|
||||
break;
|
||||
}
|
||||
if ($act === 'import') {
|
||||
// 「真的导入」:只导**勾选的那些书**(用户在预览页里挑)
|
||||
//
|
||||
// ⚠️ v24 第 25 批 用户第 8 条:**不再自己写一遍导入逻辑** ——
|
||||
// 原来这里只建 title + author + status(于是封面永远是空的),
|
||||
// 现在整份交给 `WeRead::syncNow()`:它会把封面、简介、ISBN、
|
||||
// 出版社、字数、进度**一次拿齐**,而且「只补空字段」。
|
||||
// 两处各写一遍 = 迟早只修一处(这正是"封面看不到"的来路)。
|
||||
$want = array_map('strval', (array)($_POST['pick'] ?? []));
|
||||
if (!$want) {
|
||||
flash('err', '没有勾选任何书。');
|
||||
redirect('books/weread');
|
||||
}
|
||||
UserSetting::set($me, WeRead::SYNC_AT_KEY, '0'); // 强制真同步
|
||||
$nb = WeRead::notebooks($me);
|
||||
if (!$nb['ok']) {
|
||||
flash('err', (string)$nb['error']);
|
||||
redirect('books/weread');
|
||||
}
|
||||
$familyId = (int)Auth::familyId();
|
||||
$sumBooks = $sumNotes = 0;
|
||||
$sumBooks = $sumNotes = $sumCovers = 0;
|
||||
foreach ($nb['books'] as $b) {
|
||||
if (!in_array((string)$b['bookId'], $want, true)) {
|
||||
continue;
|
||||
@@ -5579,34 +5702,70 @@ switch ($r) {
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
// 逐本走 syncNow 的那套「建书/补空」逻辑:把它当"只同步这一本"。
|
||||
// ⚠️ syncNow 是按"有笔记的书"遍历的,这里已经有确定的 bookId,
|
||||
// 所以借它内部那两个入口(bookInfo + fetchCover)手动走一遍,
|
||||
// 保持与自动同步**完全同一条路径**(封面/元数据不会两样)。
|
||||
$bid = WeRead::matchLocalBook($familyId, $title);
|
||||
$info = WeRead::bookInfo($me, (string)$b['bookId']);
|
||||
$meta = $info['ok'] ? $info['book'] : [];
|
||||
$coverKey = '';
|
||||
if (!empty($meta['cover'])) {
|
||||
$coverKey = WeRead::fetchCover($familyId, (string)$meta['cover'], $title);
|
||||
if ($coverKey !== '') { $sumCovers++; }
|
||||
}
|
||||
if ($bid <= 0) {
|
||||
$bid = Book::create($familyId, $me, [
|
||||
'title' => $title,
|
||||
'author' => (string)$b['author'],
|
||||
'author' => (string)($meta['author'] ?? $b['author']),
|
||||
'translator' => (string)($meta['translator'] ?? ''),
|
||||
'publisher' => (string)($meta['publisher'] ?? ''),
|
||||
'published' => (string)($meta['published'] ?? ''),
|
||||
'isbn' => (string)($meta['isbn'] ?? ''),
|
||||
'category' => (string)($meta['category'] ?? ''),
|
||||
'summary' => (string)($meta['intro'] ?? ''),
|
||||
'words' => (int)($meta['wordCount'] ?? 0),
|
||||
'rating' => (int)($meta['rating'] ?? 0) ?: null,
|
||||
'cover_key' => $coverKey,
|
||||
'status' => 'reading',
|
||||
]);
|
||||
if ($bid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$sumBooks++;
|
||||
} elseif ($coverKey !== '') {
|
||||
// 已在书架且没封面 → 补上(用户第 8 条「封面图看不到」)
|
||||
$row = Book::find($familyId, $bid);
|
||||
if ($row && trim((string)($row['cover_key'] ?? '')) === '') {
|
||||
WeRead::patchBook($familyId, $bid, ['cover_key' => $coverKey]);
|
||||
}
|
||||
}
|
||||
$nt = WeRead::notes($me, (string)$b['bookId']);
|
||||
if (!$nt['ok']) {
|
||||
continue;
|
||||
}
|
||||
if ($nt['ok']) {
|
||||
$res = WeRead::store($bid, $me, (array)$nt['items']);
|
||||
$sumNotes += (int)$res['added'];
|
||||
}
|
||||
}
|
||||
flash('ok', '导入完成:新建 ' . $sumBooks . ' 本书、'
|
||||
. $sumCovers . ' 张封面、'
|
||||
. $sumNotes . ' 条笔记(重复的已自动跳过)。');
|
||||
redirect('books');
|
||||
}
|
||||
}
|
||||
// ---- GET:先"顺手同步一次"(用户第 8 条:定期自动同步)----
|
||||
//
|
||||
// ⚠️ 位置很要紧:放在**渲染之前**,这样这一页看到的书架就是最新的;
|
||||
// 而 maybeSync 内部按间隔判定,绝大多数请求只是读一个时间戳(不联网)。
|
||||
$syncRes = ['ran' => false, 'books' => 0, 'notes' => 0, 'covers' => 0, 'error' => ''];
|
||||
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) {
|
||||
$syncRes = WeRead::maybeSync((int)Auth::familyId(), $me, false);
|
||||
}
|
||||
View::render('books/weread', [
|
||||
'title' => '微信读书导入',
|
||||
'preview' => $imported,
|
||||
'nbCount' => 0,
|
||||
'sync' => $syncRes,
|
||||
'stats' => WeRead::readStats($me, 'overall'),
|
||||
], 'books');
|
||||
break;
|
||||
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
/**
|
||||
* 家书 FamilyBook · 导航展示页的**头像**公开出口(v24 第 25 批 用户第 9 条新增)
|
||||
*
|
||||
* 用户原话:「导航页的展示页面上面的那个头像就变成用户自己的头像就可以了。」
|
||||
*
|
||||
* 但这里有两个约束撞在一起:
|
||||
* ① 头像一律经 `media.php` 出去,而 `media.php` **要求登录态**(未登录 401)——
|
||||
* 展示页是免登录的,拿不到;
|
||||
* ② `uploads/` 整个目录 `Require all denied`(线上实测直接取 403)。
|
||||
* ⇒ 于是展示页的头像永远取不到(正是用户报的「头像没显示」)。
|
||||
*
|
||||
* 本端点的暴露面刻意压到**最小**:
|
||||
* · 只认 `?p=<短语>`,短语必须有效(`Nav::ownerByPhrase`);
|
||||
* · **只返回这个主人自己的头像** —— 不接任何 id、不接任何别的键,
|
||||
* 所以拿别人的短语只能看到那个人自己的头像(那本来就公开在他的展示页上);
|
||||
* · 头像是**公开内容**(它天天挂在那个公开页上),不含隐私;
|
||||
* · 没有头像时 404(调用方 `onerror` 换成默认剪影,不在这里回退 ——
|
||||
* 那样"有没有设过头像"在服务端就分不清了)。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/app/bootstrap.php';
|
||||
|
||||
use App\Nav;
|
||||
use App\Person;
|
||||
use App\Storage\Storage;
|
||||
|
||||
function nav_av_fail(int $code, string $message): void
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
$phrase = trim((string)($_GET['p'] ?? ''));
|
||||
if ($phrase === '') {
|
||||
nav_av_fail(404, '没有这个头像');
|
||||
}
|
||||
$personId = Nav::ownerByPhrase($phrase);
|
||||
if ($personId <= 0) {
|
||||
nav_av_fail(404, '没有这个头像');
|
||||
}
|
||||
|
||||
$p = Person::find($personId);
|
||||
if (!$p) {
|
||||
nav_av_fail(404, '没有这个头像');
|
||||
}
|
||||
$key = trim((string)($p['avatar'] ?? ''));
|
||||
if ($key === '') {
|
||||
nav_av_fail(404, '这个人没有头像'); // 前端 onerror 会换成默认剪影
|
||||
}
|
||||
// 外部绝对地址(历史数据)→ 直接跳过去,不必代理
|
||||
if (preg_match('#^https?://#i', $key)) {
|
||||
header('Location: ' . $key, true, 302);
|
||||
exit;
|
||||
}
|
||||
// 键净化:与 media.php 同一条判据(拒绝 `..`、绝对路径、点文件)
|
||||
$key = ltrim(str_replace('\\', '/', $key), '/');
|
||||
if ($key === '' || strpos($key, "\0") !== false || strpos($key, '..') !== false
|
||||
|| !preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) {
|
||||
nav_av_fail(404, '没有这个头像');
|
||||
}
|
||||
foreach (explode('/', $key) as $seg) {
|
||||
if ($seg === '' || $seg === '.' || $seg[0] === '.') {
|
||||
nav_av_fail(404, '没有这个头像');
|
||||
}
|
||||
}
|
||||
|
||||
$familyId = (int)($p['family_id'] ?? 0);
|
||||
$res = Storage::read($familyId, $key);
|
||||
$status = (int)($res['status'] ?? 0);
|
||||
$body = (string)($res['body'] ?? '');
|
||||
if (empty($res['ok']) || $status < 200 || $status >= 300) {
|
||||
nav_av_fail($status === 404 || $status === 0 ? 404 : 502,
|
||||
$status === 404 || $status === 0 ? '没有这个头像' : '头像暂时取不到');
|
||||
}
|
||||
|
||||
$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($body) ?: '';
|
||||
if ($mime === '' || $mime === 'application/octet-stream'
|
||||
|| strpos($mime, 'text/html') !== false || strpos($mime, 'javascript') !== false) {
|
||||
$mime = 'image/jpeg'; // 头像只可能是位图;嗅探失败按图片给
|
||||
}
|
||||
if (strpos($mime, 'svg') !== false) {
|
||||
$mime = 'image/svg+xml';
|
||||
}
|
||||
|
||||
$etag = '"' . md5($key . '|' . strlen($body) . '|' . $mime) . '"';
|
||||
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
|
||||
http_response_code(304);
|
||||
header('ETag: ' . $etag);
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(200);
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . strlen($body));
|
||||
// ⚠️ 只缓存 1 天(不是 immutable):头像可以被本人随时换掉,
|
||||
// 而这里的 URL 不带版本号(键就是那个短语,换头像时短语不变)。
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('ETag: ' . $etag);
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
if ($mime === 'image/svg+xml') {
|
||||
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
||||
}
|
||||
echo $body;
|
||||
exit;
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
/**
|
||||
* 家书 FamilyBook · 导航图标**公开**出口(v24 第 25 批 用户第 9 条新增)
|
||||
*
|
||||
* 为什么需要它(两个约束撞在一起,谁都绕不开):
|
||||
* ① 家书的 `uploads/` 整个目录是 `<Require all denied>`(见 uploads/.htaccess)——
|
||||
* 所有附件一律经 `media.php` 出去,而 `media.php` **要求登录态**(未登录 401)。
|
||||
* 线上实测:直接取 `uploads/nav-icons/xxx.png` 得 **403**。
|
||||
* ② 但**导航展示页 `/nav/?<短语>` 是全站唯一免登录页**(用户明确要求),
|
||||
* 而它的图标请求也来自浏览器、同样不带登录态。
|
||||
* ⇒ 于是"要登录才给图"和"公开页面要显示图"直接冲突:图标一个都显示不出来。
|
||||
* 这正是用户报的「图标也没有」在服务端的那一半原因。
|
||||
*
|
||||
* 做法:仿 media.php 开一个**窄得多的**公开出口 ——
|
||||
* 只服务 `Nav::ICON_DIR` 这一个目录、且文件名必须严格符合
|
||||
* `md5串.<扩展名>`。别的一律 404(连目录都不列)。
|
||||
*
|
||||
* ⚠️ 安全性说明(为什么这里敢免登录):
|
||||
* · 图标是**公开网站自己的 favicon**,不含任何用户隐私;
|
||||
* · 文件名是 `md5(网址)`,且这些网址本来就列在**同样是公开的**展示页上 ——
|
||||
* 这里的暴露面不比展示页本身更大;
|
||||
* · 只能取到这一层目录里的"名字长得像图标"的文件,路径穿越 / 目录遍历都不可能;
|
||||
* · 目录写权限在部署账号手里(见部署脚本),Web 侧只读。
|
||||
*
|
||||
* 用法:`/nav_icon.php?f=<文件名>&v=<mtime>`
|
||||
* ⚠️ `v` 只用于缓存击穿,不参与鉴权(值随便传也不会取到别的文件)。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/app/bootstrap.php';
|
||||
|
||||
use App\Nav;
|
||||
|
||||
/** 统一的失败出口(与 media.php 同一套做法:不吐任何多余信息) */
|
||||
function nav_icon_fail(int $code, string $message): void
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = (string)($_GET['f'] ?? '');
|
||||
|
||||
// ---- ① 只认文件名(杜绝任何路径成分)----
|
||||
//
|
||||
// ⚠️ 用 basename 而不是"检查有没有 `..`":前者把 `a/../../x.png` 直接截成 `x.png`,
|
||||
// 连"要判断什么"都不需要判断;后者总有没想到的编码绕过(%2e%2e、双重编码…)。
|
||||
$file = basename(str_replace('\\', '/', $file));
|
||||
|
||||
// ---- ② 严格白名单:必须是「32 位十六进制 + 图片扩展名」----
|
||||
//
|
||||
// ⚠️ 这一条是**真正的门**:没有它,`f=.htaccess` 之类就能被读出去
|
||||
// (哪怕 basename 已经挡住了路径,文件名本身仍然可以是敏感文件)。
|
||||
if (!preg_match('/^[a-f0-9]{32}\.(png|jpe?g|gif|webp|svg|ico)$/i', $file)) {
|
||||
nav_icon_fail(404, '没有这个图标');
|
||||
}
|
||||
|
||||
$abs = FB_ROOT . '/' . Nav::ICON_DIR . '/' . $file;
|
||||
if (!is_file($abs)) {
|
||||
nav_icon_fail(404, '没有这个图标');
|
||||
}
|
||||
|
||||
// ---- ③ MIME:由**内容**嗅探,绝不信扩展名 ----
|
||||
//
|
||||
// (与 media.php 同一口径。SVG 会被 finfo 认成 text/plain,按扩展名补回。)
|
||||
$body = (string)@file_get_contents($abs);
|
||||
$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($body) ?: '';
|
||||
if ($mime === '' || $mime === 'application/octet-stream') {
|
||||
$mime = 'image/png';
|
||||
}
|
||||
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||
if ($ext === 'svg' || $mime === 'image/svg+xml') {
|
||||
$mime = 'image/svg+xml';
|
||||
}
|
||||
// 能被浏览器当页面/脚本执行的一律降级(与 media.php 同一条防线)
|
||||
if (strpos($mime, 'text/html') !== false || strpos($mime, 'javascript') !== false
|
||||
|| strpos($mime, 'xhtml') !== false) {
|
||||
$mime = 'application/octet-stream';
|
||||
}
|
||||
|
||||
$etag = '"' . md5($file . '|' . strlen($body)) . '"';
|
||||
$inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
|
||||
if ($inm !== '' && $inm === $etag) {
|
||||
http_response_code(304);
|
||||
header('ETag: ' . $etag);
|
||||
header('Cache-Control: public, max-age=31536000, immutable');
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(200);
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . strlen($body));
|
||||
// ⚠️ 长缓存 + immutable 是安全的:文件名里含 `md5(网址)`,换图必换名;
|
||||
// 调用方还会再挂一个 `?v=<mtime>`(见 Nav::iconUrl),双保险。
|
||||
header('Cache-Control: public, max-age=31536000, immutable');
|
||||
header('ETag: ' . $etag);
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
if ($mime === 'image/svg+xml') {
|
||||
// SVG 能带脚本 → 即便要显示,也禁止执行脚本、禁止加载外部资源
|
||||
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
||||
}
|
||||
echo $body;
|
||||
exit;
|
||||
+32
-15
@@ -80,7 +80,7 @@ if ($personId <= 0) {
|
||||
<div class="b">
|
||||
<h1>没有这个导航页</h1>
|
||||
<p>地址里的短语不对,或者主人换过短语了。<br>确认一下地址,或者找主人要新的。</p>
|
||||
<a href="<?= e(url('auth/login')) ?>">去家书登录</a>
|
||||
<a href="<?= e(Nav::abs('/' . url('auth/login'))) ?>">去家书登录</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -101,7 +101,24 @@ $sitesOf = static function (int $gid) use ($personId): array {
|
||||
};
|
||||
|
||||
$mePerson = \App\Person::find($personId);
|
||||
$meAvatar = avatar_url($mePerson['avatar'] ?? null);
|
||||
|
||||
/* 🔴🔴 展示页里的自引用地址**一律要绝对化**(v24 第 25 批 用户第 9 条修的真 bug)。
|
||||
*
|
||||
* 根因:家书的 `url()` 产出 `index.php?r=xxx`、`avatar_url()` 产出 `media.php?k=…`
|
||||
* —— 都是**相对地址**。而本页在 `/nav/` 子目录下,浏览器会把它们拼成
|
||||
* `/nav/index.php?…`、`/nav/media.php?…` —— 线上实测**全部 404**。
|
||||
* 用户看到的正是这一串:头像不显示 + 点「登录」404 + 搜索联想请求也 404。
|
||||
* 修法见 Nav::abs()(它解释了为什么只在这一页需要绝对化)。
|
||||
*
|
||||
* 头像另外有一层:`media.php` **要登录**、`uploads/` 又被 `Require all denied`
|
||||
* (线上实测 403),免登录页两头都取不到 → 所以走专门开的 `nav_avatar.php`
|
||||
* (只服务这个短语主人自己的头像)。没有头像时它 404,前端 `onerror` 换成默认剪影。 */
|
||||
$meAvatar = $mePerson && trim((string)($mePerson['avatar'] ?? '')) !== ''
|
||||
? Nav::abs('/nav_avatar.php') . '?p=' . rawurlencode($phrase)
|
||||
: '';
|
||||
$defaultAvatar = Nav::abs(asset('/assets/avatar-default.svg'));
|
||||
$loginUrl = Nav::abs('/' . url('auth/login'));
|
||||
$suggestUrl = Nav::abs('/nav_suggest.php') . '?p=' . rawurlencode($phrase);
|
||||
|
||||
// 展示页自己的标题(用设置里的站点名与副标题;副标题允许为空 → 不显示)
|
||||
$navTitle = trim((string)($settings['site_name'] ?? '')) !== '' ? (string)$settings['site_name'] : '我的导航';
|
||||
@@ -140,7 +157,7 @@ $siteTile = static function (array $s) use ($openTarget): void {
|
||||
<title><?= e($navTitle) ?><?= $navSubtitle !== '' ? ' · ' . e($navSubtitle) : '' ?></title>
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta name="theme-color" content="<?= e($primary) ?>">
|
||||
<link rel="stylesheet" href="<?= e(asset('/assets/css/nav-show.css')) ?>">
|
||||
<link rel="stylesheet" href="<?= e(Nav::abs(asset('/assets/css/nav-show.css'))) ?>">
|
||||
<?php if ($primary !== '#2F6BF7'): ?>
|
||||
<style>:root { --n-primary: <?= e($primary) ?>; }</style>
|
||||
<?php endif; ?>
|
||||
@@ -153,12 +170,11 @@ $siteTile = static function (array $s) use ($openTarget): void {
|
||||
因为导航展示页本来就不需要登录,这个按钮是给"主人自己"用的便利入口。 */ ?>
|
||||
<div class="topline">
|
||||
<div class="who">
|
||||
<?php if ($meAvatar !== ''): ?>
|
||||
<img class="who-av" src="<?= e($meAvatar) ?>" alt="">
|
||||
<?php endif; ?>
|
||||
<img class="who-av" src="<?= e($meAvatar !== '' ? $meAvatar : $defaultAvatar) ?>"
|
||||
alt="" onerror="this.onerror=null;this.src='<?= e($defaultAvatar) ?>';">
|
||||
<span class="who-name"><?= e($navTitle) ?></span>
|
||||
</div>
|
||||
<a class="login-btn" href="<?= e(url('auth/login')) ?>">
|
||||
<a class="login-btn" href="<?= e($loginUrl) ?>">
|
||||
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12.5 6.5V5a1.5 1.5 0 0 0-1.5-1.5H5A1.5 1.5 0 0 0 3.5 5v10A1.5 1.5 0 0 0 5 16.5h6a1.5 1.5 0 0 0 1.5-1.5v-1.5"/>
|
||||
@@ -169,9 +185,8 @@ $siteTile = static function (array $s) use ($openTarget): void {
|
||||
</div>
|
||||
|
||||
<header class="hero">
|
||||
<?php if ($meAvatar !== ''): ?>
|
||||
<img class="logo" src="<?= e($meAvatar) ?>" alt="">
|
||||
<?php endif; ?>
|
||||
<img class="logo" src="<?= e($meAvatar !== '' ? $meAvatar : $defaultAvatar) ?>"
|
||||
alt="" onerror="this.onerror=null;this.src='<?= e($defaultAvatar) ?>';">
|
||||
<h1><?= e($navTitle) ?></h1>
|
||||
<?php if ($navSubtitle !== ''): ?>
|
||||
<p><?= e($navSubtitle) ?></p>
|
||||
@@ -179,7 +194,7 @@ $siteTile = static function (array $s) use ($openTarget): void {
|
||||
</header>
|
||||
|
||||
<?php if ($engines): ?>
|
||||
<div class="s-search">
|
||||
<div id="search" class="s-search">
|
||||
<div class="search-type">
|
||||
<?php foreach ($engines as $en): ?>
|
||||
<label class="s-engine<?= $default && $en['slug'] === $default['slug'] ? ' active' : '' ?>"
|
||||
@@ -262,7 +277,7 @@ $siteTile = static function (array $s) use ($openTarget): void {
|
||||
<?php endif; ?>
|
||||
|
||||
<footer class="foot">
|
||||
<a href="<?= e(url('auth/login')) ?>">登录家书</a>
|
||||
<a href="<?= e($loginUrl) ?>">登录家书</a>
|
||||
<span>·</span>
|
||||
<span><?= e($navTitle) ?></span>
|
||||
</footer>
|
||||
@@ -270,12 +285,14 @@ $siteTile = static function (array $s) use ($openTarget): void {
|
||||
|
||||
<script>
|
||||
/* 联想词来源:走家书自己的代理(不直接把第三方接口暴露给浏览器 ——
|
||||
SimpleNav 原来也是走自己的 suggest.php,同一个理由:跨域 + 不必要的直连)。 */
|
||||
SimpleNav 原来也是走自己的 suggest.php,同一个理由:跨域 + 不必要的直连)。
|
||||
⚠️ v24 第 25 批:地址必须用 Nav::abs() **绝对化** —— 本页在 /nav/ 下,
|
||||
相对地址会被拼成 /nav/nav_suggest.php…(线上实测 404,下拉永远空)。 */
|
||||
window.SIMPLENAV = {
|
||||
suggest: <?= json_encode(url('nav/suggest') . '?p=' . rawurlencode($phrase), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
|
||||
suggest: <?= json_encode($suggestUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
|
||||
suggestKeys: <?= json_encode($suggestKeys) ?>
|
||||
};
|
||||
</script>
|
||||
<script src="<?= e(asset('/assets/js/nav-show.js')) ?>"></script>
|
||||
<script src="<?= e(Nav::abs(asset('/assets/js/nav-show.js'))) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
|
||||
+103
-3
@@ -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']) ?>
|
||||
@@ -323,3 +353,73 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</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; ?>
|
||||
+108
-56
@@ -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) {
|
||||
// ① 两处标签的高亮保持同步(同一个筛选器)
|
||||
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 picked = [];
|
||||
|
||||
// ② 总金额图:只显示匹配的那一张
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-tkind]'), function (w) {
|
||||
w.hidden = (w.getAttribute('data-ck-tkind') || '') !== kind;
|
||||
});
|
||||
// 图上的标题跟着换(不然选着"存款",标题还写"净资产",自相矛盾)
|
||||
var ttl = document.querySelector('[data-ck-total-title]');
|
||||
if (ttl) {
|
||||
ttl.textContent = kind === '' ? '总金额 · 净资产' : (word + ' · 金额变化');
|
||||
/** 读一遍某颗标签上带的说明数据(合计 / 笔数 / 类别名) */
|
||||
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) {
|
||||
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') || '') !== sole;
|
||||
});
|
||||
var ttl = document.querySelector('[data-ck-total-title]');
|
||||
if (ttl) {
|
||||
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; ?>
|
||||
|
||||
+133
-4
@@ -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' ?>">
|
||||
<?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