Files
FamilyBook/assets/js/filter.js
T
laoyang e0562b8627 v24 第 5 轮:十条清单(天气填城市 / 输入即筛选 / 看板列=看板 / 账本盘点折线图 / 纪念日分类)
用户 2026-10-03 的十条,逐条落点:

1. 天气改为**个人设置里填城市**(每人一份):新增 app/Geo.php,内置 197 个
   地级市坐标(离线、逐条核对,生成脚本 _tooling/_fb_geo_gen.py)。填坐标仍
   作为内置表没有时的出口;老的家庭级坐标保留为兜底,升级当天天气不会凭空消失。
2. 所有"找个人"的地方**输入即筛选**:新增 assets/js/filter.js(纯前端显隐,
   不发请求、回车不刷新)+ app/PersonQuery.php 把服务端判据收敛成一份
   (名册 / 挑人 / 高级搜索共用,避免三处实现漂移)。
3. 名册列表:有昵称就不重复显示本名、行变矮、补齐手机/阴历阳历生日(带年份)/
   邮箱/工作单位;「清除」与「搜索」同款同尺寸。
4/5. 日程:撤掉左侧日历本栏改满宽,功能(显隐/颜色/导入/默认视图)全收进
   右侧 ☰;**色块高度改为按起止时长**(旧实现一律 30 分钟,三小时的会跟
   十五分钟的同高);点日历空白处即建日程,开始时间=点的那一格。
6/10. 资源:搜索提到最上;按"最近改动/最新新增"排序,默认只显示最新 10 条并
   给「显示全部」出口。
7. 任务:看板的**列=用户自定义的看板**(不再是写死的待办/进行中),
   拖动/左右键=换看板(改 board_id,不动 status)。
8. 财务:概览在上账目在下、记一笔改右侧按钮、账本设置(共享/改名/新建/删除)
   收进 ☰、默认账本名「xxx的账本」、"挂在谁名下"默认自己且用标准选人框;
   **新增盘点**(新增 ledger_entry_snapshots 只增不改的历史表,旧值不被清空)
   + 上面画金额变化折线图(app/chart.php 服务端画 SVG,零依赖)。
9. 纪念日:手动添加的与名册生日派生的**分两块**、手动的在上;右侧设置可增删
   分类(家庭级,删分类不强改已用的历史)。

本轮另外修掉/加固的四类问题(都由新增的静态或运行时守卫兜住):
- index.php 少写 2 个 use 导入(Pinyin / UserSetting)→ 名册与个人设置
  返回 200 但正文是 Fatal;顺带修了一处早已存在的 Attachment 未导入。
  → 新增 _fb_import_check.py(1f 段)。
- 一次不精确替换弄丢了 app/Calendar.php 开头的 `<?php`,整份源码被当 HTML
  吐给浏览器,而 `php -l` 报"无语法错误"(纯文本输出确实合法)。
  → 新增 _fb_php_open_check.py(1e 段)。
- 模板片段 unset 了宿主模板的变量(账本片段 unset($canWrite))→ 正文里一片
  Undefined variable 而 HTTP 仍 200。同 _datetime.php 早年 $t 的坑。
  → 新增 _fb_fragment_scope_check.py(1g 段)+ _fb_batch_e.py 的 26 页无告警巡检。
- 窄屏下页头动作区不换行 → 周视图横向溢出且按钮被挤成三种高度(用户提过三次
  "按钮要同高")→ .page-head .actions 加 flex-wrap。

验证战绩(全绿):
  e2e 773/0 · 第 5 轮专项 91/0 · 结构升级 126/0 · 几何 428/0 · 移动端 684/0 ·
  交互 97/0 · 家谱 26/0 · 按钮 60/0;三轮反向验证各 3/3 抓到,还原后复绿。
线上:schema v14、指纹一致(9df6c9fccc6796db)、snapshots 表已建、197 城、
  8 个关键文件 md5 与本地逐字节一致、app/ 403 无源码泄漏、错误日志无新增。
2026-10-03 15:01:38 +08:00

151 lines
6.6 KiB
JavaScript
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.
/**
* 输入即筛选(v24 用户 2026-10-03 第 2 条)
*
* 用户原话:「所有人员检索的地方,输入汉字或者首字母,自动筛选,而不需要按回车。」
*
* 这个模块只做**前端显隐**:数据已经全在页面上了,输入就立刻筛,不发请求。
* 为什么不做成"输入 → AJAX 重查":
* · 名册 / 挑人 / 选人弹窗都是几十条的小列表,整份渲染出来的成本可以忽略;
* · 一发请求就要处理"响应回来时输入框已经变了""光标跳到末尾"这些竞态,
* 而这个功能的价值恰恰是"打字的时候列表在动" —— 有一帧延迟就不对了;
* · 没 JS 时页面上的「搜索」按钮/form 提交照旧能用(渐进增强,不是二选一)。
*
* 三种用法(都是声明式,靠 data-* 挂上去,不用改 JS):
*
* ① 列表行:<input data-filter="#roster"> … 行上带 data-search="姓名 称呼 邮箱 首字母"
* 行被筛掉 = hidden 属性(⚠️ CSS 里必须给 [hidden] 写 display:none,
* 作者样式里的 display:flex 会盖过浏览器默认的 [hidden] —— 项目里踩过两次)
*
* ② 下拉选项:<input data-filter-select="#foo"> → 把不匹配的 <option> 藏起来。
* 应用在"共享给谁 / 负责人 / 纪要人"这类原生下拉上(名册一长就难找)。
*
* ③ 计数与空态:容器上带 data-filter-count="#id"(显示"命中 N 条")、
* data-filter-empty="#id"(一条都没命中时露出来的提示)。
*
* 拼音首字母:服务端已经算好放进 data-initials(见 App\Pinyin::initials)。
* 这里对 data-search 做"包含",对 data-initials 做**子序列**(yyq / yy / y 都能中)。
*/
(function () {
'use strict';
/** q 是否是 s 的子序列(与 person_picker.js 同一判据) */
function isSubSeq(q, s) {
if (!q) { return true; }
var i = 0;
for (var j = 0; j < s.length && i < q.length; j++) {
if (s.charAt(j) === q.charAt(i)) { i++; }
}
return i === q.length;
}
/** 一个元素能不能被 q 命中 */
function hit(el, q) {
if (!q) { return true; }
var hay = (el.getAttribute('data-search') || '').toLowerCase();
if (hay.indexOf(q) >= 0) { return true; }
var ini = (el.getAttribute('data-initials') || '').toLowerCase();
return ini ? isSubSeq(q, ini) : false;
}
function bindList(input) {
var sel = input.getAttribute('data-filter');
var box = sel ? document.querySelector(sel) : null;
if (!box) { return; }
// 行本身可能不是"直接子元素",统一找带 data-search 的
var items = box.querySelectorAll('[data-search]');
var countEl = input.getAttribute('data-filter-count')
? document.querySelector(input.getAttribute('data-filter-count')) : null;
var emptyEl = input.getAttribute('data-filter-empty')
? document.querySelector(input.getAttribute('data-filter-empty')) : null;
function apply() {
var q = (input.value || '').trim().toLowerCase();
var shown = 0;
Array.prototype.forEach.call(items, function (el) {
var ok = hit(el, q);
el.hidden = !ok;
if (ok) { shown++; }
});
if (countEl) { countEl.textContent = q === '' ? '' : ('命中 ' + shown + ' 条'); }
if (emptyEl) { emptyEl.hidden = !(q !== '' && shown === 0); }
// 分组栏里一个人都不剩时,整栏也收起来(否则留一排空标题)
Array.prototype.forEach.call(box.querySelectorAll('[data-filter-group]'), function (g) {
var n = 0;
Array.prototype.forEach.call(g.querySelectorAll('[data-search]'), function (el) {
if (!el.hidden) { n++; }
});
g.hidden = n === 0;
});
}
input.addEventListener('input', apply);
// ⚠️ 回车**不要**提交表单:一提交页面就整页刷新、输入框失焦,
// 而列表已经实时筛过了 —— 用户按回车只会看到"闪一下、什么都没变"。
var form = input.form;
if (form) {
form.addEventListener('submit', function (e) {
// 没有 JS 时的兜底是 form 提交;有 JS 时拦下来,避免无意义的整页刷新。
if (input.getAttribute('data-filter-submit') !== '1') {
e.preventDefault();
apply();
}
});
}
apply();
// 清除按钮:与「搜索」同形同尺寸(用户第 3 条),点了 = 清空 + 立刻重筛
var clearSel = input.getAttribute('data-filter-clear');
if (clearSel) {
Array.prototype.forEach.call(document.querySelectorAll(clearSel), function (btn) {
btn.addEventListener('click', function () {
input.value = '';
apply();
input.focus();
});
});
}
return apply;
}
function bindSelect(input) {
var box = document.querySelector(input.getAttribute('data-filter-select'));
if (!box) { return; }
var opts = Array.prototype.slice.call(box.options);
// 每个 option 首次筛之前先备份(中文名 + 服务端给的 data-initials)
Array.prototype.forEach.call(opts, function (o) {
if (o.getAttribute('data-initials') === null) {
o.setAttribute('data-initials', (o.getAttribute('data-initials-raw') || '').toLowerCase());
}
if (o.getAttribute('data-search') === null) {
o.setAttribute('data-search', (o.textContent || '').trim().toLowerCase());
}
});
input.addEventListener('input', function () {
var q = (input.value || '').trim().toLowerCase();
Array.prototype.forEach.call(opts, function (o) {
if (o.value === '' || o.value === '0') { o.hidden = false; return; } // 留「全部/不指定」
var hay = o.getAttribute('data-search') || '';
var ini = o.getAttribute('data-initials') || '';
o.hidden = !(q === '' || hay.indexOf(q) >= 0 || (ini && isSubSeq(q, ini)));
});
// 当前选中的那项被筛掉了就把选择收回到「全部/不指定」,免得提交一个看不见的值
if (box.selectedOptions.length && box.selectedOptions[0].hidden) {
box.selectedIndex = 0;
}
});
}
function boot() {
// 给页面一个"我到了"的标记:有些页面(名册的「清除」按钮)要据此决定
// 是走"就地清空重筛"还是回落到跳转。没这个标记它们只能靠猜。
window.__fbFilterLoaded = true;
Array.prototype.forEach.call(document.querySelectorAll('[data-filter]'), bindList);
Array.prototype.forEach.call(document.querySelectorAll('[data-filter-select]'), bindSelect);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();