v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:
- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
(实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
金额一律以「分」存,不用浮点。
验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 全局人员选择器(v24 用户第 11 条)
|
||||
*
|
||||
* 一个页面里可能有多个 .pp(观影同行人 / 旅程同行人 / 资源归属 / 共享给谁 …),
|
||||
* 所以这里按容器逐个初始化,状态全挂在容器自己身上,互不影响。
|
||||
*
|
||||
* 三件事:
|
||||
* ① 汉字 → 拼音首字母(Intl 拼音排序 + 23 个锚字二分,零依赖)
|
||||
* ② 搜索:名字 / 显示名 / 首字母 三者任一命中就留下
|
||||
* ③ chips 与弹窗里的勾选状态双向同步
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 汉字 → 拼音首字母
|
||||
*
|
||||
* 做法:a..z 各挑一个「锚字」(没有 i / u / v 开头的常用字,所以是 23 个),
|
||||
* 用拼音 collation 二分定位某个汉字落在哪一段 —— 落在「垃」之后、「妈」之前
|
||||
* 就是 l。整套零依赖,不需要塞一张几万字的拼音表进来。
|
||||
*
|
||||
* ⚠️ 浏览器不支持拼音排序时返回 null:此时只按名字搜,不硬出错误结果。
|
||||
* ---------------------------------------------------------------- */
|
||||
var initialsOf = (function () {
|
||||
var anchors = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝';
|
||||
var letters = 'abcdefghjklmnopqrstwxyz';
|
||||
var coll;
|
||||
try { coll = new Intl.Collator('zh-Hans-CN-u-co-pinyin'); } catch (e) { return null; }
|
||||
// 排序没真正生效时 compare 会退化成按码点/笔画,这里直接认不出来 → 放弃拼音
|
||||
if (!coll || coll.compare('阿', '匝') >= 0) { return null; }
|
||||
return function (s) {
|
||||
var out = '';
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
var ch = s.charAt(i);
|
||||
if (/[a-zA-Z]/.test(ch)) { out += ch.toUpperCase(); continue; }
|
||||
if (ch.charCodeAt(0) < 0x2E80) { continue; } // 非 CJK:不产字母
|
||||
var lo = 0, hi = anchors.length - 1, best = -1;
|
||||
while (lo <= hi) {
|
||||
var mid = (lo + hi) >> 1;
|
||||
if (coll.compare(ch, anchors.charAt(mid)) >= 0) { best = mid; lo = mid + 1; }
|
||||
else { hi = mid - 1; }
|
||||
}
|
||||
if (best >= 0) { out += letters.charAt(best); }
|
||||
}
|
||||
return out;
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* q 是否是 s 的**子序列**。
|
||||
* 为什么不用 indexOf:「杨永全」的首字母是 yyq,用户很自然地会打 yq ——
|
||||
* 连续子串匹配不到,子序列能。选人这种小列表宁可放宽一点,也别让人以为坏了。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
function init(root) {
|
||||
var id = root.getAttribute('data-pp') || '';
|
||||
var isMulti = root.getAttribute('data-pp-mode') !== 'single';
|
||||
var modal = root.querySelector('.pp-modal');
|
||||
var openBtn = root.querySelector('.pp-open');
|
||||
var closeBtn = root.querySelector('.pp-close');
|
||||
var search = root.querySelector('.pp-search');
|
||||
var list = root.querySelector('.pp-list');
|
||||
var chips = root.querySelector('.pp-chips');
|
||||
var emptyTip = root.querySelector('.pp-empty');
|
||||
if (!modal || !list || !chips) { return; }
|
||||
|
||||
// 预计算每个人的首字母串,挂在 data-initials 上(列表再长也只是算一次)
|
||||
Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) {
|
||||
var s = (el.getAttribute('data-name') || '') + (el.getAttribute('data-shown') || '');
|
||||
var ini = initialsOf ? initialsOf(s) : '';
|
||||
el.setAttribute('data-initials', ini.toLowerCase());
|
||||
var badge = el.querySelector('.pp-initials');
|
||||
if (badge && ini) { badge.textContent = ini; badge.hidden = false; }
|
||||
});
|
||||
|
||||
function syncChips() {
|
||||
chips.innerHTML = '';
|
||||
var any = false;
|
||||
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input:checked'), function (box) {
|
||||
if (box.value === '0') { return; } // 「不选」不进 chips
|
||||
var item = box.closest('.pp-item');
|
||||
if (!item) { return; }
|
||||
any = true;
|
||||
// ⚠️ 必须点名取 .pp-name:.pp-item 里第一个 span 是隐藏的首字母占位,
|
||||
// 早年靠「取第一个 span 当名字」碰巧成立,加了首字母徽标后 chip 就没名字了。
|
||||
var nameEl = item.querySelector('.pp-name');
|
||||
var av = item.querySelector('.pp-av');
|
||||
var chip = document.createElement('span');
|
||||
chip.className = 'pp-chip';
|
||||
chip.setAttribute('data-id', box.value);
|
||||
if (av) {
|
||||
var im = document.createElement('img');
|
||||
im.src = av.getAttribute('src'); // 服务端已 e() 过,这里原样搬
|
||||
im.alt = '';
|
||||
chip.appendChild(im);
|
||||
}
|
||||
var nm = document.createElement('span');
|
||||
nm.className = 'pp-name';
|
||||
nm.textContent = nameEl ? nameEl.textContent : box.value;
|
||||
chip.appendChild(nm);
|
||||
if (isMulti) {
|
||||
var x = document.createElement('button');
|
||||
x.type = 'button'; x.className = 'pp-x'; x.textContent = '×';
|
||||
x.title = '移除'; x.setAttribute('data-id', box.value);
|
||||
chip.appendChild(x);
|
||||
}
|
||||
chips.appendChild(chip);
|
||||
});
|
||||
// ⚠️ 多选且一个都没选时**不要**塞占位 chip:.pp-chip 自己写了 display:inline-flex,
|
||||
// 再给它 hidden 也藏不掉(作者样式压过 UA 的 [hidden]),会留下一个空胶囊。
|
||||
if (!any && !isMulti) {
|
||||
var ph = document.createElement('span');
|
||||
ph.className = 'pp-chip pp-chip-empty';
|
||||
ph.textContent = '未选择';
|
||||
chips.appendChild(ph);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
var q = (search.value || '').trim().toLowerCase();
|
||||
var shown = 0;
|
||||
Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) {
|
||||
var hit = !q ||
|
||||
(el.getAttribute('data-name') || '').toLowerCase().indexOf(q) >= 0 ||
|
||||
(el.getAttribute('data-shown') || '').toLowerCase().indexOf(q) >= 0 ||
|
||||
isSubSeq(q, el.getAttribute('data-initials') || '');
|
||||
el.hidden = !hit;
|
||||
if (hit) { shown++; }
|
||||
});
|
||||
if (emptyTip) { emptyTip.hidden = shown > 0; }
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
modal.hidden = false;
|
||||
search.value = '';
|
||||
applyFilter();
|
||||
syncChips();
|
||||
search.focus();
|
||||
}
|
||||
function closeModal() { modal.hidden = true; }
|
||||
|
||||
if (openBtn) { openBtn.addEventListener('click', openModal); }
|
||||
if (closeBtn) { closeBtn.addEventListener('click', closeModal); }
|
||||
modal.addEventListener('click', function (e) { if (e.target === modal) { closeModal(); } });
|
||||
search.addEventListener('input', applyFilter);
|
||||
|
||||
chips.addEventListener('click', function (e) {
|
||||
var x = e.target.closest ? e.target.closest('.pp-x') : null;
|
||||
if (!x) { return; }
|
||||
var gid = x.getAttribute('data-id');
|
||||
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input'), function (box) {
|
||||
if (box.value === gid) { box.checked = false; }
|
||||
});
|
||||
syncChips();
|
||||
});
|
||||
|
||||
/* 多选:勾一个 = 出 chip + 关弹窗(再添加就再点一次「+ 添加人」);
|
||||
单选:点一个 = 换掉当前这个人 + 关弹窗 */
|
||||
list.addEventListener('change', function (e) {
|
||||
if (!e.target || e.target.tagName !== 'INPUT') { return; }
|
||||
syncChips();
|
||||
if (e.target.checked) { closeModal(); }
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !modal.hidden) { closeModal(); }
|
||||
});
|
||||
|
||||
syncChips();
|
||||
}
|
||||
|
||||
function boot() {
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.pp'), init);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 全局附件预览(v24 用户第 3 条)
|
||||
*
|
||||
* 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。
|
||||
* · 图片 → <img>
|
||||
* · 视频 → <video controls autoplay>(media.php 支持 Range,进度条能拖)
|
||||
* · PDF → <iframe>(用浏览器自带的阅读器)
|
||||
* · 其它(Word / Excel / …)→ 根本不进这里:那段链接指向的是下载地址,点了就下。
|
||||
*
|
||||
* 右上角恒有一个「下载」按钮(用户原话:所有的预览页面,右上角都有一个下载按钮)。
|
||||
*
|
||||
* 触发方式是数据属性,不用给每个地方写一遍初始化:
|
||||
* <a href="…" data-prev="image|video|pdf" data-src="…" data-dl="…" data-name="…">
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var box = document.getElementById('pv');
|
||||
if (!box) { return; }
|
||||
var stage = box.querySelector('.pv-stage');
|
||||
var titleEl = box.querySelector('.pv-name');
|
||||
var metaEl = box.querySelector('.pv-meta');
|
||||
var dlEl = box.querySelector('.pv-dl');
|
||||
var closeEl = box.querySelector('.pv-close');
|
||||
var opener = null;
|
||||
|
||||
function clear() {
|
||||
// ⚠️ 换内容要**整个重建**节点,不能只改 src:
|
||||
// <video> 改 src 不会自动卸载上一支(声音会叠着放),<iframe> 同理。
|
||||
stage.innerHTML = '';
|
||||
}
|
||||
|
||||
function open(trigger) {
|
||||
var kind = trigger.getAttribute('data-prev') || 'image';
|
||||
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
|
||||
var dl = trigger.getAttribute('data-dl') || src;
|
||||
if (!src) { return; }
|
||||
opener = trigger;
|
||||
clear();
|
||||
|
||||
var el;
|
||||
if (kind === 'video') {
|
||||
el = document.createElement('video');
|
||||
el.className = 'pv-video';
|
||||
el.src = src;
|
||||
el.controls = true;
|
||||
el.autoplay = true;
|
||||
el.playsInline = true;
|
||||
} else if (kind === 'pdf') {
|
||||
el = document.createElement('iframe');
|
||||
el.className = 'pv-pdf';
|
||||
el.src = src;
|
||||
// PDF 是"另一份文档",别让它拿到本页的 window
|
||||
el.setAttribute('sandbox', 'allow-same-origin allow-scripts');
|
||||
} else {
|
||||
el = document.createElement('img');
|
||||
el.className = 'pv-img';
|
||||
el.src = src;
|
||||
el.alt = '';
|
||||
}
|
||||
stage.appendChild(el);
|
||||
|
||||
titleEl.textContent = trigger.getAttribute('data-name') || '';
|
||||
var sz = trigger.getAttribute('data-size') || '';
|
||||
metaEl.textContent = sz;
|
||||
dlEl.setAttribute('href', dl);
|
||||
// 下载要带上真实文件名:download 属性 + 服务端 Content-Disposition 双保险
|
||||
var nm = trigger.getAttribute('data-name') || '';
|
||||
if (nm) { dlEl.setAttribute('download', nm); } else { dlEl.removeAttribute('download'); }
|
||||
|
||||
box.hidden = false;
|
||||
box.setAttribute('aria-hidden', 'false');
|
||||
closeEl.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
box.hidden = true;
|
||||
box.setAttribute('aria-hidden', 'true');
|
||||
clear(); // 停掉视频 / 卸掉 PDF
|
||||
if (opener && opener.focus) { opener.focus(); }
|
||||
opener = null;
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target && e.target.closest ? e.target.closest('[data-prev]') : null;
|
||||
if (t) {
|
||||
// ⚠️ 只拦"可预览"的:Word/Excel 那类链接没有 data-prev,
|
||||
// 浏览器照常走 href(就是下载地址),不会被这里挡住。
|
||||
e.preventDefault();
|
||||
open(t);
|
||||
return;
|
||||
}
|
||||
// 点遮罩(stage 本身或弹层空白处)关闭;点内容不关
|
||||
if (!box.hidden && (e.target === box || e.target === stage)) { close(); }
|
||||
});
|
||||
|
||||
closeEl.addEventListener('click', close);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!box.hidden && e.key === 'Escape') { close(); }
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user