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:
+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">
|
||||
|
||||
Reference in New Issue
Block a user