名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标
用户本轮 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() 不再报错。
335 lines
15 KiB
PHP
335 lines
15 KiB
PHP
<?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>
|