Files
FamilyBook/app/Pinyin.php
T
laoyang 02f5d072a4 家谱卡片重做 / 首页控制台 / 名册改列表 / 重复日程与旅程公开
用户 2026-10-03 提的四条:

① 家谱卡片重做
   · 去掉「⇄ 姻亲」按钮,改成**每个人**卡片右下角都有「以 TA 为中心」
     (点了以他重画一张家谱,涵盖原来的姻亲场景)。
   · 头像外框改**长方形**(96×72,宽度贴合头像),object-fit: contain 不裁切,
     姓名/称呼在头像下方。
   · 修「点右下角按钮误进成员页」:卡片不再整张是 <a>,
     只有姓名是链接,右下角是独立的 <button>(物理上分开,不抢点击)。

② 家谱文案与按钮清理
   · 去掉副标题与下方图例说明;整块去掉「看全部 / 回到我 / 回到我的家谱」
     (顺带消掉"出现两个回到我的家谱")。镜头由库自动 fit,拖拽/滚轮即可。

③ 首页控制台
   · 标题下显示个人 slogan;去掉右侧三个统计;「我的资料/我的家谱」换成图标按钮;
     去掉与导航条重复的「名册」按钮。
   · 去掉「接下来的日子」(生日归到"未来七天"里,不再重复)。
   · 新增统计信息块:按时段随机问候 + 今日天气(Open-Meteo,免 key,可留空不显示)
     + 近七天日程/待办/逾期数。
   · 新增各模块小结卡(任务/日程/纪念日/观影/旅程/财务/名册/家谱/资源)。
   · 个人设置新增「首页上的模块」:可勾选与拖动排序(每人一份,存 user_settings)。
   · 保留「往年的今天」。

④ 名册改列表
   · 卡片网格 → **竖向列表**:左头像、中间姓名+与我的关系+手机号+生日。
   · 修拖动「只能往后不能往前」:落点改为显式记录"插到谁前面/后面",
     并排除被拖元素自身。
   · 去掉那句拖动提示(能力保留)。
   · 搜索新增**姓名首字母**检索(y / yy / yyq 都命中「杨永全」):
     新 app/Pinyin.php,intl 优先 + 内置常用字表兜底(本地无 intl 也能测);
     纯字母/数字查询词不走 SQL LIKE(拼音与加密手机号在库里都比不了)。

附带上两轮的收尾:
   · 菜单点外部自动关闭(全局事件委托,覆盖所有 details.menu)。
   · 旅程 is_public:列表与日历只显示公开的;详情页不拦(不丢数据)。
   · 重复日程:新 app/Repeat.php(RRULE 解析/生成/展开,含"工作日"),
     新表 calendar_event_skips(RFC 的 EXDATE);日历按规则展开;
     编辑支持三档「全部 / 这一次及以后 / 仅此一次」。
   · 结束时间可添加/去掉,并随开始时间联动。

验证:端到端 772 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 26、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 11:30:48 +08:00

299 lines
17 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
/**
* 汉字 → 拼音首字母(用户 2026-10-03 第 4 条)。
*
* 用途:名册搜索支持「姓名首字母」——
* 输入 y / yy / yyq 都能搜到「杨永全」。
*
* 两条路(**必须都有**,因为本地与线上环境不同):
*
* ① `Intl` 扩展的 `Collator('zh-Hans-CN-u-co-pinyin')`:拿一张
* 「a..z 各挑一个锚字」的表做二分,就能判断任意汉字落在哪个字母段。
* 线上 PHP 7.4/8.1 都有 intl,走这条最准、零数据。
* ② 没有 intl 时的兜底:**常用姓氏/人名用字表**。
* 本地开发环境没有 intl(实测 `intl: 无`),没有兜底就等于
* 「这个功能本地永远测不了」—— 那是最糟的情况,只能上线才发现问题。
*
* ⚠️ 兜底表覆盖不到的生僻字会**原样保留首字符参与匹配**(不产字母),
* 也就是说:搜不到时是"少匹配",绝不会错匹配。
*/
declare(strict_types=1);
namespace App;
final class Pinyin
{
/** 拼音排序用的锚字(a..z 各一个;没有 i/u/v 开头的常用字,所以是 23 个) */
private const ANCHORS = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝';
private const LETTERS = 'abcdefghjklmnopqrstwxyz';
/**
* 常用姓氏 + 常见人名字的**首字母表**(兜底用)。
* 只列"作为名字第一个字"出现频率高的;查不到就交给下一档处理。
* 这一份不可能穷尽,所以它只是兜底 —— 线上有 intl 时走的是锚字二分。
*/
private const TABLE = [
// 姓氏(覆盖绝大多数中国姓氏)
'赵a' => 1, '钱q' => 1, '孙s' => 1, '李l' => 1, '周z' => 1, '吴w' => 1, '郑z' => 1,
'王w' => 1, '冯f' => 1, '陈c' => 1, '褚c' => 1, '卫w' => 1, '蒋j' => 1, '沈s' => 1,
'韩h' => 1, '杨y' => 1, '朱z' => 1, '秦q' => 1, '尤y' => 1, '许x' => 1, '何h' => 1,
'吕l' => 1, '施s' => 1, '张z' => 1, '孔k' => 1, '曹c' => 1, '严y' => 1, '华h' => 1,
'金j' => 1, '魏w' => 1, '陶t' => 1, '姜j' => 1, '戚q' => 1, '谢x' => 1, '邹z' => 1,
'喻y' => 1, '柏b' => 1, '水s' => 1, '窦d' => 1, '章z' => 1, '云y' => 1, '苏s' => 1,
'潘p' => 1, '葛g' => 1, '奚x' => 1, '范f' => 1, '彭p' => 1, '郎l' => 1, '鲁l' => 1,
'韦w' => 1, '昌c' => 1, '马m' => 1, '苗m' => 1, '凤f' => 1, '花h' => 1, '方f' => 1,
'俞y' => 1, '任r' => 1, '袁y' => 1, '柳l' => 1, '酆f' => 1, '鲍b' => 1, '史s' => 1,
'唐t' => 1, '费f' => 1, '廉l' => 1, '岑c' => 1, '薛x' => 1, '雷l' => 1, '贺h' => 1,
'倪n' => 1, '汤t' => 1, '滕t' => 1, '殷y' => 1, '罗l' => 1, '毕b' => 1, '郝h' => 1,
'邬w' => 1, '安a' => 1, '常c' => 1, '乐y' => 1, '于y' => 1, '时s' => 1, '傅f' => 1,
'皮p' => 1, '卞b' => 1, '齐q' => 1, '康k' => 1, '伍w' => 1, '余y' => 1, '元y' => 1,
'卜b' => 1, '顾g' => 1, '孟m' => 1, '平p' => 1, '黄h' => 1, '和h' => 1, '穆m' => 1,
'萧x' => 1, '尹y' => 1, '姚y' => 1, '邵s' => 1, '湛z' => 1, '汪w' => 1, '祁q' => 1,
'毛m' => 1, '禹y' => 1, '狄d' => 1, '米m' => 1, '贝b' => 1, '明m' => 1, '臧z' => 1,
'计j' => 1, '伏f' => 1, '成c' => 1, '戴d' => 1, '谈t' => 1, '宋s' => 1, '茅m' => 1,
'庞p' => 1, '熊x' => 1, '纪j' => 1, '舒s' => 1, '屈q' => 1, '项x' => 1, '祝z' => 1,
'董d' => 1, '梁l' => 1, '杜d' => 1, '阮r' => 1, '蓝l' => 1, '闵m' => 1, '席x' => 1,
'季j' => 1, '麻m' => 1, '强q' => 1, '贾j' => 1, '路l' => 1, '娄l' => 1, '危w' => 1,
'江j' => 1, '童t' => 1, '颜y' => 1, '郭g' => 1, '梅m' => 1, '盛s' => 1, '林l' => 1,
'刁d' => 1, '钟z' => 1, '徐x' => 1, '邱q' => 1, '骆l' => 1, '高g' => 1, '夏x' => 1,
'蔡c' => 1, '田t' => 1, '樊f' => 1, '胡h' => 1, '凌l' => 1, '霍h' => 1, '虞y' => 1,
'万w' => 1, '支z' => 1, '柯k' => 1, '昝z' => 1, '管g' => 1, '卢l' => 1, '莫m' => 1,
'房f' => 1, '裘q' => 1, '缪m' => 1, '干g' => 1, '解x' => 1, '应y' => 1, '宗z' => 1,
'丁d' => 1, '宣x' => 1, '邓d' => 1, '单s' => 1, '杭h' => 1, '洪h' => 1, '包b' => 1,
'诸z' => 1, '左z' => 1, '石s' => 1, '崔c' => 1, '吉j' => 1, '钮n' => 1, '龚g' => 1,
'程c' => 1, '嵇j' => 1, '邢x' => 1, '滑h' => 1, '裴p' => 1, '陆l' => 1, '荣r' => 1,
'翁w' => 1, '荀x' => 1, '羊y' => 1, '於y' => 1, '惠h' => 1, '甄z' => 1, '曲q' => 1,
'封f' => 1, '芮r' => 1, '羿y' => 1, '储c' => 1, '靳j' => 1, '汲j' => 1, '邴b' => 1,
'糜m' => 1, '松s' => 1, '井j' => 1, '段d' => 1, '富f' => 1, '巫w' => 1, '乌w' => 1,
'焦j' => 1, '巴b' => 1, '弓g' => 1, '牧m' => 1, '隗k' => 1, '山s' => 1, '谷g' => 1,
'车c' => 1, '侯h' => 1, '宓m' => 1, '蓬p' => 1, '全q' => 1, '郗x' => 1, '班b' => 1,
'仰y' => 1, '秋q' => 1, '仲z' => 1, '伊y' => 1, '宫g' => 1, '宁n' => 1, '仇q' => 1,
'栾l' => 1, '暴b' => 1, '甘g' => 1, '厉l' => 1, '戎r' => 1, '祖z' => 1, '武w' => 1,
'符f' => 1, '刘l' => 1, '景j' => 1, '詹z' => 1, '束s' => 1, '龙l' => 1, '叶y' => 1,
'幸x' => 1, '司s' => 1, '韶s' => 1, '郜g' => 1, '黎l' => 1, '蓟j' => 1, '薄b' => 1,
'印y' => 1, '宿s' => 1, '白b' => 1, '怀h' => 1, '蒲p' => 1, '邰t' => 1, '从c' => 1,
'鄂e' => 1, '索s' => 1, '咸x' => 1, '籍j' => 1, '赖l' => 1, '卓z' => 1, '蔺l' => 1,
'屠t' => 1, '蒙m' => 1, '池c' => 1, '乔q' => 1, '阴y' => 1, '郁y' => 1, '胥x' => 1,
'能n' => 1, '苍c' => 1, '双s' => 1, '闻w' => 1, '莘s' => 1, '党d' => 1, '翟z' => 1,
'谭t' => 1, '贡g' => 1, '劳l' => 1, '逄p' => 1, '姬j' => 1, '申s' => 1, '扶f' => 1,
'堵d' => 1, '冉r' => 1, '宰z' => 1, '郦l' => 1, '雍y' => 1, '郤x' => 1, '璩q' => 1,
'桑s' => 1, '桂g' => 1, '濮p' => 1, '牛n' => 1, '寿s' => 1, '通t' => 1, '边b' => 1,
'扈h' => 1, '燕y' => 1, '冀j' => 1, '郏j' => 1, '浦p' => 1, '尚s' => 1, '农n' => 1,
'温w' => 1, '别b' => 1, '庄z' => 1, '晏y' => 1, '柴c' => 1, '瞿q' => 1, '阎y' => 1,
'充c' => 1, '慕m' => 1, '连l' => 1, '茹r' => 1, '习x' => 1, '宦h' => 1, '艾a' => 1,
'鱼y' => 1, '容r' => 1, '向x' => 1, '古g' => 1, '易y' => 1, '慎s' => 1, '戈g' => 1,
'廖l' => 1, '庾y' => 1, '终z' => 1, '暨j' => 1, '居j' => 1, '衡h' => 1, '步b' => 1,
'都d' => 1, '耿g' => 1, '满m' => 1, '弘h' => 1, '匡k' => 1, '国g' => 1, '文w' => 1,
'寇k' => 1, '广g' => 1, '禄l' => 1, '阙q' => 1, '东d' => 1, '欧o' => 1, '殳s' => 1,
'沃w' => 1, '利l' => 1, '蔚w' => 1, '越y' => 1, '夔k' => 1, '隆l' => 1, '师s' => 1,
'巩g' => 1, '厍s' => 1, '聂n' => 1, '晁c' => 1, '勾g' => 1, '敖a' => 1, '融r' => 1,
'冷l' => 1, '訾z' => 1, '辛x' => 1, '阚k' => 1, '那n' => 1, '简j' => 1, '饶r' => 1,
'空k' => 1, '曾z' => 1, '毋w' => 1, '沙s' => 1, '乜n' => 1, '养y' => 1, '鞠j' => 1,
'须x' => 1, '丰f' => 1, '巢c' => 1, '关g' => 1, '蒯k' => 1, '相x' => 1, '查z' => 1,
'后h' => 1, '荆j' => 1, '红h' => 1, '游y' => 1, '竺z' => 1, '权q' => 1, '逯l' => 1,
'盖g' => 1, '益y' => 1, '桓h' => 1, '公g' => 1,
// 名字里高频的字
'永y' => 1, '全q' => 1, '博b' => 1, '为w' => 1, '明m' => 1, '华h' => 1, '建j' => 1,
'国g' => 1, '文w' => 1, '军j' => 1, '平p' => 1, '志z' => 1, '强q' => 1, '磊l' => 1,
'洋y' => 1, '勇y' => 1, '艳y' => 1, '杰j' => 1, '娟j' => 1, '涛t' => 1, '超c' => 1,
'敏m' => 1, '静j' => 1, '丽l' => 1, '秀x' => 1, '英y' => 1, '娜n' => 1, '丹d' => 1,
'玲l' => 1, '芳f' => 1, '燕y' => 1, '红h' => 1, '霞x' => 1, '梅m' => 1, '兰l' => 1,
'琴q' => 1, '云y' => 1, '飞f' => 1, '鹏p' => 1, '宇y' => 1, '浩h' => 1, '鑫x' => 1,
'亮l' => 1, '刚g' => 1, '辉h' => 1, '峰f' => 1, '龙l' => 1, '波b' => 1, '斌b' => 1,
'春c' => 1, '雨y' => 1, '晨c' => 1, '阳y' => 1, '雪x' => 1, '悦y' => 1, '欣x' => 1,
'怡y' => 1, '佳j' => 1, '思s' => 1, '雨y' => 1, '子z' => 1, '小x' => 1, '大d' => 1,
'老l' => 1, '爸b' => 1, '妈m' => 1, '哥g' => 1, '姐j' => 1, '弟d' => 1, '妹m' => 1,
'爷y' => 1, '奶n' => 1, '叔s' => 1, '姨y' => 1, '舅j' => 1, '嫂s' => 1, '儿e' => 1,
'女n' => 1, '宝b' => 1, '乐l' => 1, '安a' => 1, '康k' => 1, '福f' => 1, '寿s' => 1,
'喜x' => 1, '顺s' => 1, '和h' => 1, '美m' => 1, '好h' => 1, '新x' => 1, '中z' => 1,
'正z' => 1, '长c' => 1, '海h' => 1, '江j' => 1, '河h' => 1, '山s' => 1, '林l' => 1,
'木m' => 1, '金j' => 1, '玉y' => 1, '石s' => 1, '田t' => 1, '力l' => 1, '学x' => 1,
'生s' => 1, '成c' => 1, '立l' => 1, '德d' => 1, '仁r' => 1, '义y' => 1, '礼l' => 1,
'智z' => 1, '信x' => 1, '忠z' => 1, '孝x' => 1, '友y' => 1, '爱a' => 1, '慈c' => 1,
// 家谱称呼里高频的那批字(父/母/伯/叔/姑/舅/姨/岳…)——
// 名册搜索经常会拿这些字当关键词,兜底表里缺了就会出现
// "本地搜 s 搜不到『张三』"这种与环境相关的怪现象。
'父f' => 1, '母m' => 1, '祖z' => 1, '孙s' => 1, '外w' => 1, '表b' => 1, '堂t' => 1,
'侄z' => 1, '甥s' => 1, '婿x' => 1, '媳x' => 1, '伯b' => 1, '姑g' => 1, '岳y' => 1,
'公g' => 1, '婆p' => 1, '妻q' => 1, '夫f' => 1, '丈z' => 1, '娘n' => 1, '爹d' => 1,
'三s' => 1, '四s' => 1, '五w' => 1, '六l' => 1, '七q' => 1, '八b' => 1, '九j' => 1,
'十s' => 1, '百b' => 1, '千q' => 1, '万w' => 1, '一y' => 1, '二e' => 1, '零l' => 1,
'不b' => 1, '的d' => 1, '了l' => 1, '在z' => 1, '有y' => 1, '是s' => 1, '我w' => 1,
'人r' => 1, '家j' => 1, '门m' => 1, '户h' => 1, '先x' => 1, '后h' => 1, '晚w' => 1,
'长c' => 1, '幼y' => 1, '大d' => 1, '小x' => 1, '多d' => 1, '少s' => 1, '高g' => 1,
'矮a' => 1, '胖p' => 1, '瘦s' => 1, '黑h' => 1, '白b' => 1, '花h' => 1, '草c' => 1,
'树s' => 1, '叶y' => 1, '果g' => 1, '苗m' => 1, '阳y' => 1, '光g' => 1, '月y' => 1,
'星x' => 1, '风f' => 1, '雪x' => 1, '雨y' => 1, '雷l' => 1, '电d' => 1, '冰b' => 1,
'州z' => 1, '城c' => 1, '村c' => 1, '镇z' => 1, '县x' => 1, '市s' => 1, '省s' => 1,
'华h' => 1, '青q' => 1, '岛d' => 1, '京j' => 1, '津j' => 1, '冀j' => 1, '鲁l' => 1,
'思s' => 1, '念n' => 1, '想x' => 1, '记j' => 1, '忘w' => 1, '知z' => 1, '道d' => 1,
'说s' => 1, '话h' => 1, '听t' => 1, '看k' => 1, '走z' => 1, '来l' => 1, '去q' => 1,
'回h' => 1, '到d' => 1, '给g' => 1, '拿n' => 1, '做z' => 1, '用y' => 1, '买m' => 1,
'卖m' => 1, '吃c' => 1, '喝h' => 1, '睡s' => 1, '起q' => 1, '坐z' => 1, '站z' => 1,
'跑p' => 1, '跳t' => 1, '唱c' => 1, '写x' => 1, '读d' => 1, '画h' => 1, '算s' => 1,
'作z' => 1, '课k' => 1, '班b' => 1, '校x' => 1, '师s' => 1, '医y' => 1, '院y' => 1,
'药y' => 1, '病b' => 1, '健j' => 1, '体t' => 1, '身s' => 1, '心x' => 1, '手s' => 1,
'脚j' => 1, '眼y' => 1, '耳e' => 1, '鼻b' => 1, '口k' => 1, '头t' => 1, '发f' => 1,
];
/** 首字母查表:'杨' => 'y'。首次调用时从 TABLE 摊平,之后走内存缓存。 */
private static ?array $map = null;
public static function initialMap(): array
{
if (self::$map === null) {
self::$map = [];
foreach (self::TABLE as $kv => $_) {
$k = (string)$kv;
if (mb_strlen($k, 'UTF-8') >= 1) {
$ch = mb_substr($k, 0, 1, 'UTF-8');
$ch = mb_substr($k, 0, mb_strlen($k, 'UTF-8') - 1, 'UTF-8');
$letter = mb_substr((string)$kv, mb_strlen((string)$kv, 'UTF-8') - 1, 1, 'UTF-8');
if ($ch !== '' && $letter !== '') {
self::$map[$ch] = strtolower($letter);
}
}
}
}
return self::$map;
}
/**
* 一个字的拼音首字母(小写)。认不出时返回 ''。
* 有 intl 走锚字二分;没有就走内置表。
*/
public static function initial(string $ch): string
{
if ($ch === '') {
return '';
}
// 已经是 ASCII 字母 → 直接用
if (preg_match('/^[a-zA-Z]$/', $ch)) {
return strtolower($ch);
}
if (preg_match('/^[0-9]$/', $ch)) {
return $ch;
}
// ① intl 拼音排序(线上有)
$viaIntl = self::initialViaIntl($ch);
if ($viaIntl !== null) {
return $viaIntl;
}
// ② 内置表兜底(本地无 intl 时)
return self::initialMap()[$ch] ?? '';
}
/** 用 Intl 的拼音 collation + 锚字二分定位;没有 intl 返回 null */
private static function initialViaIntl(string $ch): ?string
{
static $coll = false;
if ($coll === false) {
$coll = null;
if (class_exists('\\Collator')) {
$c = new \Collator('zh-Hans-CN-u-co-pinyin');
// 排序没真的生效时(compare('阿','匝') 不 < 0)就别用,免得给出错结果
if ($c->compare('阿', '匝') < 0) {
$coll = $c;
}
}
}
if ($coll === null) {
return null;
}
$anchors = self::ANCHORS;
$len = mb_strlen($anchors, 'UTF-8');
$lo = 0;
$hi = $len - 1;
$best = -1;
while ($lo <= $hi) {
$mid = intdiv($lo + $hi, 2);
$a = mb_substr($anchors, $mid, 1, 'UTF-8');
if ($coll->compare($ch, $a) >= 0) {
$best = $mid;
$lo = $mid + 1;
} else {
$hi = $mid - 1;
}
}
if ($best < 0) {
return null;
}
return mb_substr(self::LETTERS, $best, 1, 'UTF-8');
}
/**
* 一串字 → 首字母串(小写)。认不出的字**跳过**(不产字母,也不报错)。
* 例:'杨永全' → 'yyq'。
*/
public static function initials(string $s): string
{
$out = '';
$n = mb_strlen($s, 'UTF-8');
for ($i = 0; $i < $n; $i++) {
$out .= self::initial(mb_substr($s, $i, 1, 'UTF-8'));
}
return $out;
}
/**
* q 是否是 s 的**子序列**(用于"输入 y 也能搜到 yyq"这类前缀式检索)。
*
* 为什么用子序列而不是前缀:
* 用户可能只记得中间某个字(比如"永全" → yq),或干脆跳着打;
* 名册这种小列表宁可宽松一点,也别让人以为搜索坏了。
*/
public static function isSubsequence(string $q, string $s): bool
{
$q = strtolower(trim($q));
$s = strtolower($s);
if ($q === '') {
return true;
}
$i = 0;
$qn = strlen($q);
for ($j = 0, $sn = strlen($s); $j < $sn && $i < $qn; $j++) {
if ($s[$j] === $q[$i]) {
$i++;
}
}
return $i === $qn;
}
/**
* 这个人是否匹配搜索词。
*
* 覆盖:姓名 / 称呼 / 邮箱 / 手机号 的**模糊**包含,以及姓名首字母的**子序列**匹配。
*
* @param array $row 名册行(含 name / email / phone_plain,可选 my_nickname)
* @param string $q 搜索词
*/
public static function matches(array $row, string $q): bool
{
$q = trim($q);
if ($q === '') {
return true;
}
$needle = mb_strtolower($q, 'UTF-8');
foreach (['name', 'my_nickname', 'nickname', 'email', 'phone_plain'] as $k) {
$v = mb_strtolower(trim((string)($row[$k] ?? '')), 'UTF-8');
if ($v !== '' && mb_strpos($v, $needle) !== false) {
return true;
}
}
// 首字母:只对**字母/数字**查询词做子序列匹配,
// 中文查询词走上面的"包含"(否则"永全"会被当成首字母串去比对,永远匹配不上)
if (preg_match('/^[a-z0-9]+$/', $needle)) {
$ini = self::initials((string)($row['name'] ?? ''));
if ($ini !== '' && self::isSubsequence($needle, $ini)) {
return true;
}
// 称呼也参与首字母(比如称呼是"小宝" → xb)
$nickIni = self::initials((string)($row['my_nickname'] ?? ''));
if ($nickIni !== '' && self::isSubsequence($needle, $nickIni)) {
return true;
}
}
return false;
}
}