Files
FamilyBook/app/PersonTag.php
T
laoyang 6852b4ee2c v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式
其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:

1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
   → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
   修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
   「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
   (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
   新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
   🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
   单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
   全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
2026-10-08 02:20:19 +08:00

187 lines
6.9 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
/**
* 名册标签(v28 用户 2026-10-07 第 5 条)。
*
* 用户原话:「名册的分组,用下来不太好,我感觉应该改成打标签,就是说,一个人,
* 他可能并不是只在一个分组,例如,他既是我的小学同学,又是我的初中同学,
* 就没法分组,改成标签,似乎更合理。」
*
* 于是把「一人一组」换成「一人多标签」。三条关键设计:
*
* ① **每个人一份**(`owner_id`)—— 与 person_roster 同口径。
* 我给他打「小学同学」,不该让全家的名册都多出这个标签;
* 而且同一个人在我这儿是「球友」、在我妈那儿是「外甥」,本来就该各说各的。
*
* ② **标签词直接存字符串,不建标签表** —— 与笔记模块同一条轻量取舍。
* 建一张标签表就要处理"改名后历史怎么办""两个标签合并",那是重型知识库才值
* 的复杂度;这里标签就是"随手打的词",靠 GROUP BY 现算完事。
*
* ③ **不动 person_roster.group_id** —— 那一列是 vCard CATEGORIES 与老代码的读点,
* 删掉会让"没跟上升级"的地方静默少一个字段。旧分组值已在 Schema 里
* 一次性迁成同名标签(幂等),之后两边各走各的:新界面只认标签。
*/
declare(strict_types=1);
namespace App;
final class PersonTag
{
/** 标签长度与数量上限(挡住"把一句话当标签") */
public const MAX_LEN = 24;
public const MAX_EACH = 12;
/**
* 我的名册里每个人的标签:person_id => [tag, tag, ...]
*
* 一次取完(列表页要整页渲染),不做 N+1 —— 名册上千人时那是致命的。
*/
public static function mapFor(int $familyId, int $ownerId): array
{
if ($familyId <= 0 || $ownerId <= 0) {
return [];
}
$st = Database::get()->prepare(
'SELECT person_id, tag FROM person_tags
WHERE family_id = ? AND owner_id = ?
ORDER BY person_id, position, id'
);
$st->execute([$familyId, $ownerId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['person_id']][] = (string)$r['tag'];
}
return $out;
}
/** 单个(详情/编辑页用) */
public static function of(int $ownerId, int $personId): array
{
if ($ownerId <= 0 || $personId <= 0) {
return [];
}
$st = Database::get()->prepare(
'SELECT tag FROM person_tags WHERE owner_id = ? AND person_id = ?
ORDER BY position, id'
);
$st->execute([$ownerId, $personId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[] = (string)$r['tag'];
}
return $out;
}
/**
* 我用过的全部标签 + 各挂了几个(顶部筛选条用)。
*
* ⚠️ 只统计**我的名册里还在的人** —— 否则会出现"标签上写着 5 个,
* 点进去只有 3 个"(另外 2 个已经被移出名册,标签行还在)。
*/
public static function cloud(int $familyId, int $ownerId): array
{
if ($familyId <= 0 || $ownerId <= 0) {
return [];
}
$st = Database::get()->prepare(
'SELECT t.tag, COUNT(*) AS n
FROM person_tags t
JOIN person_roster r ON r.owner_id = t.owner_id AND r.person_id = t.person_id
WHERE t.family_id = ? AND t.owner_id = ?
GROUP BY t.tag
ORDER BY n DESC, t.tag ASC'
);
$st->execute([$familyId, $ownerId]);
return $st->fetchAll();
}
/** 建议用的标签词(写标签时的联想;按人气排序) */
public static function suggest(int $familyId, int $ownerId, int $limit = 30): array
{
$out = [];
foreach (self::cloud($familyId, $ownerId) as $r) {
$out[] = (string)$r['tag'];
if (count($out) >= $limit) {
break;
}
}
return $out;
}
/**
* 整组替换某人的标签(名册卡片上改标签就用它)。
*
* 为什么是"整组替换"而不是逐个增删:界面是一次提交一整套标签的,
* 逐个 diff 反而要处理"哪些是新增/哪些是删除",代码多且容易漏。
* 标签没有历史价值,全量重写最简单可靠。
*
* @param array|string $raw 数组,或一段用空格/逗号分隔的标签
* @return array<int,string> 归一后真正写进去的标签(调用方可以回显)
*/
public static function setFor(int $familyId, int $ownerId, int $personId, $raw): array
{
if ($familyId <= 0 || $ownerId <= 0 || $personId <= 0) {
return [];
}
$tags = self::clean($raw);
$db = Database::get();
$db->prepare('DELETE FROM person_tags WHERE owner_id = ? AND person_id = ?')
->execute([$ownerId, $personId]);
if (!$tags) {
return [];
}
$st = $db->prepare(
'INSERT INTO person_tags (family_id, owner_id, person_id, tag, position)
VALUES (?,?,?,?,?)'
);
$pos = 10;
foreach ($tags as $t) {
$st->execute([$familyId, $ownerId, $personId, $t, $pos]);
$pos += 10;
}
return $tags;
}
/** 删某人时把他的标签一起删(persons 外键也会级联,这里显式一次更稳)。 */
public static function forgetPerson(int $personId): void
{
if ($personId <= 0) {
return;
}
Database::get()->prepare('DELETE FROM person_tags WHERE person_id = ?')->execute([$personId]);
}
/**
* 标签归一:`#小学同学` / `小学同学 ` / `小学,同学` 都要变成干净的词。
*
* 与 Note::cleanTags 同一套规则(去 `#`、去空白、去重、限长限数)——
* 两处各写一份迟早不一致(用户在笔记里能打 `#`,在名册里却打不了,最恼人)。
*/
public static function clean($raw): array
{
$list = [];
if (is_array($raw)) {
$list = $raw;
} elseif (is_string($raw)) {
$list = preg_split('/[\s,,]+/u', $raw) ?: [];
}
$out = [];
foreach ($list as $t) {
$t = trim((string)$t);
$t = ltrim($t, '#');
// 空白与 `#` 都不能出现在标签内部(它们是分隔符)
$t = (string)preg_replace('/[\s#]+/u', '', $t);
if ($t === '') {
continue;
}
$t = mb_substr($t, 0, self::MAX_LEN, 'UTF-8');
if (!in_array($t, $out, true)) {
$out[] = $t;
}
if (count($out) >= self::MAX_EACH) {
break;
}
}
return $out;
}
}