Files
FamilyBook/templates/books/weread.php
T
laoyang a5e559b2b3 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() 不再报错。
2026-10-07 11:35:31 +08:00

335 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 微信读书导入(v24 第 24 批 用户第 5 条;**第 25 批 用户第 8 条加了同步与统计**)。
*
* 用户第 8 条原话:「微信读书导入,需要能够做到定期完成,自动同步。
* 现在可以导入了,不过封面图看不到,另外也缺少一些对微信读书的
* 统计功能,需要加强。」
*
* ⇒ 这一页现在四块:
* ① 授权(填 API Key)
* ② **同步**(自动开关 + 立即同步 + 上次/下次时间)—— 第 8 条新增
* ③ 拉取预览(勾选要导的书;**每本带封面图**)—— 封面是第 8 条修的
* ④ **统计**(阅读时长 / 天数 / 偏好分类 / 读得最多的书)—— 第 8 条新增
* ⑤ 另一条路:手动粘贴
*
* @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);
$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>
<span class="sub">填一次 API Key,之后就能直接把你微信读书里的划线与想法导进来</span>
<div class="actions">
<?php $bkSetCurrent = 'import'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php /* ==================== ① 授权(填 API Key)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
授权
<?php if ($hasKey): ?>
<span class="node-badge">已授权</span>
<?php else: ?>
<span class="node-badge">还没授权</span>
<?php endif; ?>
</h2>
<p class="small muted" style="margin:0 0 12px">
去 <a href="https://weread.qq.com/r/weread-skills" target="_blank" rel="noopener">微信读书开放平台</a>
扫码登录,页面上点「获取 API Key」,会得到一串 <code class="mono">wrk-</code> 开头的字符串,
复制到下面就行。
</p>
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:8px 12px;border-radius:8px">
<b>这串 Key 等于你的账号凭据</b> —— 我们会<b>加密存放</b>,页面上也只显示掩码;
不想再用时,把它清空保存即可(或去开放平台重置那串 Key)。
</p>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('books/weread')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="key_save">
<div class="field">
<label>微信读书 API Key</label>
<input class="input mono" name="api_key" autocomplete="off"
placeholder="<?= $hasKey ? e($masked) . '(已保存,留空并保存 = 清除)' : 'wrk-xxxxxxxxxxxx' ?>">
<span class="tip">
<?php if ($hasKey): ?>
当前已保存:<b class="mono"><?= e($masked) ?></b>。要换就把新的整串贴进来(会覆盖)。
<?php else: ?>
形如 <code class="mono">wrk-NVbX…dgAA</code>,只在你自己的账号里可见。
<?php endif; ?>
</span>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn" type="submit">保存</button>
</div>
</form>
<?php else: ?>
<p class="empty-hint">只读账号不能配置导入。</p>
<?php endif; ?>
</div>
<?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): ?>
<div class="card card-pad">
<h2 class="cinema-h2">从微信读书取数据</h2>
<form method="post" action="<?= e(url('books/weread')) ?>" class="inline-form">
<?= csrf_field() ?>
<input type="hidden" name="act" value="fetch">
<div class="btn-row" style="margin-top:0">
<button class="btn btn-sm" type="submit">拉取我的笔记清单</button>
</div>
<p class="tip" style="margin-top:6px">
会去微信读书取一次数据(你有笔记的书 + 每本书的封面/划线/想法),可能要十几秒。
</p>
</form>
</div>
<?php endif; ?>
<?php if ($preview !== null): ?>
<div class="card card-pad">
<h2 class="cinema-h2">
发现 <?= (int)$nbCount ?> 本有笔记的书
<span class="hint">勾选要导入的,然后点下面的「导入选中」</span>
</h2>
<?php if (!$preview): ?>
<p class="empty-hint">微信读书里还没有带笔记的书。</p>
<?php else: ?>
<form method="post" action="<?= e(url('books/weread')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="import">
<div class="wr-list">
<?php foreach ($preview as $p): ?>
<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'] !== ''): ?>
<span class="small muted"><?= e((string)$p['author']) ?></span>
<?php endif; ?>
<span class="node-badge"><?= (int)$p['count'] ?> 条</span>
<?php if ((int)$p['local'] > 0): ?>
<span class="node-badge">书架里已有</span>
<?php else: ?>
<span class="node-badge">会新建</span>
<?php endif; ?>
</span>
<?php if (!empty($p['items'])): ?>
<span class="wr-sample">
<?php foreach ($p['items'] as $it): ?>
<span class="wr-q">
<?php if ((string)($it['quote'] ?? '') !== ''): ?>
<i><?= e(mb_substr((string)$it['quote'], 0, 60, 'UTF-8')) ?></i>
<?php endif; ?>
<?php if ((string)($it['content'] ?? '') !== ''): ?>
<em><?= e(mb_substr((string)$it['content'], 0, 60, 'UTF-8')) ?></em>
<?php endif; ?>
</span>
<?php endforeach; ?>
</span>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>
<div class="btn-row">
<button class="btn" type="submit">导入选中</button>
</div>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ==================== ⑤ 另一条路:手动粘贴 ====================
⚠️ 两种方式都留着,不是重复:
· API Key 走"全自动",但要求用户愿意把 Key 交出来;
· 粘贴走"零授权",App 里「导出笔记」复制一段文字即可。 */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">或者:手动粘贴笔记</h2>
<p class="small muted" style="margin:0 0 10px">
不想配 Key 也行 —— 在微信读书 App 里打开一本书 → 右上角「…」→ 导出笔记,
把复制到的文字粘到那里,效果一样。
</p>
<div class="btn-row" style="margin-top:0">
<a class="btn btn-ghost btn-sm" href="<?= e(url('books/notes_import')) ?>">去粘贴导入</a>
</div>
</div>