Files
FamilyBook/templates/days/index.php
T
laoyang 7544fb9f6a v41 第 41 批:流水线提速 + 12 项界面/交互开发 + DAV 彻底修复
DAV(用户「务必彻底解决」):
- 真根因是 home-set 层级错:CalDav::HOME_PATH 原为 /dav/calendars/、
  CardDav 原为 /carddav/addressbooks/,而日历本/通讯录的 href 在
  /dav/N.ics/ 与 /carddav/N.vcf/ —— home-set 下面一本都没有。
  Thunderbird 按 home-set 前缀认成员,于是「找不到日历本 / 找不到联系人」。
  改为 /dav/ 与 /carddav/(所有既有 URL 一字不变,老配置不作废)。
- 又发现并修掉 Depth:0 违反 RFC 4918:原实现无论 depth 都列出全部子集合,
  且第一条 response 是日历本(不含 current-user-principal)。改为按 depth 分流,
  Depth:0 只回资源自身、Depth:1 才列出子集合。

界面与交互:
- 观影:新建影院弹窗层级修正 + 详细地址;照片左右按钮悬停不再位移;
  弹框底栏改「确定 / 取消」。
- 旅程:新建旅程改弹窗并按观影式排版;详情页四个动作按钮按用户指定顺序与配色;
  编辑与新增行程弹窗化(单容器 + AJAX);行程时间控件改 AntD(精确到分);
  参与人默认带上自己;车次号/车牌号紧跟交通方式词。
- 资源:新建/编辑改弹窗 + 观影式排版 + 新建时即定共享人。
- 纪念日:新建改弹框 + 观影式排版。

验证:
- e2e 1510/0;v41 十二项真机 48/0;DAV 反向验证 8/0;
  部署断言空跑 337/0;模板 div 深度三形态穷举 33 组全配平。
- 线上 DAV 终验(--resolve 真实入口 + 真 DAV 口令)12/0;
  线上确认 Depth:0 恰好 1 个 response。
2026-10-11 10:12:11 +08:00

302 lines
13 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
/**
* 纪念日(v24 用户第 13 条;2026-10-03 第 9 条重排;**2026-10-07 第 2 条加「关联人」列**)
*
* 第 9 条原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日,
* 要分开,手动纪念日在上面。在新建纪念日和右侧,也增加设置按钮,
* 可以设置纪念日的分类。」
*
* 第 2 条原话:「纪念日模块,每个纪念日现在只能关联一个人,应该可以关联多个人。
* 和选择观影人一样,多选模式。自己添加的纪念日,右侧按钮,和其他模块的按钮
* 样式保持一致,修改用蓝色,删除用黄色。再加一列,关联人。列表中,
* 人的前面,都加头像。」
*
* ⇒ 三块(顺序按用户第 9 条要求):
* ① 接下来的日子(未来 60 天)
* ② **手动添加的**纪念日(在上)—— 可改可删;右侧按钮:改=蓝(.btn)、删=黄(.btn-warn)
* ③ 来自名册生日的(在下,实时派生、不能改删)
*
* @param array $custom 用户自建的(行上带 person_ids)
* @param array $derived 从名册生日派生的(行上也有 person_ids)
* @param array $upcoming 接下来 60 天的
* @param array $kinds 分类清单(设置里维护)
* @param array $people 名册(把 person id 换成头像 + 称呼)
*/
$canWrite = \App\Auth::canWrite();
$kindBadge = static function (array $r): string {
$k = (string)($r['kind'] ?? '');
return $k === '' ? '' : '<span class="node-badge">' . e($k) . '</span>';
};
$srcWord = static function (array $r): string {
$src = (string)($r['source'] ?? 'custom');
if ($src === 'birthday_solar') {
return '名册生日(阳历)';
}
if ($src === 'birthday_lunar') {
return '名册生日(农历)';
}
return \App\Anniversary::repeatWord((string)($r['repeat_type'] ?? 'solar'));
};
/* 关联人(用户第 2 条:「再加一列,关联人。列表中,人的前面,都加头像。」)
—— 用全站统一的 person_chip()(它自带头像 + 我对他/她的称呼 + 本人底色)。
⚠️ chips 一律**不传 href**:这一格在一张表格里,`<a>` 套 `<a>` 是非法 HTML,
浏览器会把外层结构切开(person_chip 的注释里记过这个坑)。
⚠️ $peopleMap 由路由层传进来(一次查全,避免逐行查库);缺失时退化成"有几个显示几个"。 */
$peopleMap = $peopleMap ?? [];
$peopleCell = static function (array $r) use ($peopleMap): string {
$ids = (array)($r['person_ids'] ?? []);
if (!$ids) {
return '<span class="small muted">—</span>';
}
$out = '';
$n = 0;
foreach ($ids as $pid) {
$pid = (int)$pid;
$p = $peopleMap[$pid] ?? null;
if ($p === null) {
continue;
}
// 一个人时正常画;多人时最多画 4 个 + 「+N」,免得一行被撑爆
if ($n >= 4) {
$rest = count($ids) - 4;
$out .= '<span class="node-badge" title="还有 ' . $rest . ' 人">+' . $rest . '</span>';
break;
}
$out .= person_chip($p);
$n++;
}
return $out === '' ? '<span class="small muted">—</span>' : $out;
};
/* 画一张纪念日表。两块(手动 / 派生)共用同一个渲染 —— 复制一遍迟早改漏一处。 */
$renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge, $srcWord, $peopleCell): void {
if (!$rows) {
return;
}
?>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>日期</th><th>纪念日</th><th>关联人</th><th>分类</th><th>重复</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td class="nowrap"><?= (int)$r['month'] ?> 月 <?= (int)$r['day'] ?> 日</td>
<td>
<?= e((string)$r['title']) ?>
<?php if (!empty($r['is_leap'])): ?><span class="small muted">(闰月)</span><?php endif; ?>
</td>
<td class="ann-people"><?= $peopleCell($r) ?></td>
<td><?= $kindBadge($r) ?></td>
<td class="small muted"><?= e($srcWord($r)) ?></td>
<td class="nowrap ann-acts">
<?php if ($editable && $canWrite): ?>
<?php /* 用户第 2 条:「右侧按钮,和其他模块的按钮样式保持一致,
修改用蓝色,删除用黄色。」
→ 改 = `.btn`(主按钮的**蓝**),删 = `.btn-warn`(**黄**实心)。
⚠️ 原来用的是 `.link-quiet`(纯文字链接)+ `.btn-ghost`(白底描边),
两者都"看不出来是什么动作",与观影/旅程行上那套不一致。 */ ?>
<a class="btn btn-sm" href="<?= e(url('days/edit', ['id' => (int)$r['id']])) ?>">修改</a>
<form method="post" action="<?= e(url('days/destroy')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
<button class="btn btn-warn btn-sm" type="submit"
onclick="return confirm('删掉这条纪念日?')">删除</button>
</form>
<?php else: ?>
<span class="small muted">来自名册</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
};
?>
<div class="page-head">
<h1>纪念日</h1>
<span class="sub">生日本身就是一种纪念日 · 阳历与农历都能记</span>
<div class="actions">
<?php if ($canWrite): ?>
<?php /* 🔴 v41(用户 2026-10-10 第 10 条):「新建纪念日,采用弹框形式,
排版参考观影模块。」⇒ 改成按钮 + 弹框(原来是跳 `days/new` 页)。
⚠️ 保留 data-href:JS 没跑时它仍是可直接访问的链接。 */ ?>
<button class="btn" type="button" id="anNewBtn"
data-href="<?= e(url('days/new')) ?>"
aria-haspopup="dialog" aria-expanded="false">新建纪念日</button>
<?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('calendar')) ?>">看日历</a>
<?php /* 用户第 9 条:设置按钮就在「新建纪念日」右边(☰,排最右) */ ?>
<?php $setCurrent = 'kinds'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php if ($canWrite): ?>
<?php /* ---- 新建纪念日弹框(内嵌同一份 form.php;初始关闭)---- */ ?>
<?php
$anModalOnly = true;
$anOpen = false;
$anModal = true;
$anEmbed = false;
$anShell = true;
require __DIR__ . '/form.php';
unset($anModalOnly, $anOpen, $anModal, $anEmbed, $anShell);
?>
<script>
/* ==================== v41:新建纪念日弹框(事件委托 + AJAX)====================
与观影/旅程同一套做法与理由(详见 _tooling 记忆):
· 事件委托(挂 document)—— 校验失败时服务端会把弹框整块重画,
旧元素连监听一起丢;委托一次绑定永久有效。
· 提交主路走 click(表单外的提交按钮上 document 的 submit 监听收不到)。
· 成功后只换 #anListHost,不整页跳。 */
(function () {
if (!window.fetch || !window.FormData) { return; }
function card() { return document.getElementById('anNewCard'); }
function setOpen(on) {
var c = card();
if (!c) { return; }
c.hidden = !on;
var b = document.getElementById('anNewBtn');
if (b) { b.setAttribute('aria-expanded', on ? 'true' : 'false'); }
if (on) {
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
var t = document.getElementById('an-title');
if (t) { t.focus(); }
}
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
if (t.closest('#anNewBtn')) { e.preventDefault(); setOpen(true); return; }
if (t.closest('#anNewX') || t.closest('#anNewCancel')) { setOpen(false); return; }
var c = card();
if (c && t === c) { setOpen(false); }
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') { return; }
var c = card();
if (!c || c.hidden) { return; }
var d = document.querySelector('.ant-picker-dropdown');
if (d && !d.classList.contains('ant-picker-dropdown-hidden')) { return; }
setOpen(false);
});
function save() {
var form = document.getElementById('anForm');
if (!form) { return; }
var s = document.getElementById('anNewSubmit');
if (s) { s.disabled = true; s.textContent = '保存中…'; }
fetch(form.action, {
method: 'POST', body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
}).then(function (r) {
var ct = (r.headers.get('content-type') || '');
if (ct.indexOf('application/json') >= 0) {
return r.json().then(function (j) {
var old = card();
if (!old || !j || !j.modal) { return; }
var tmp = document.createElement('div');
tmp.innerHTML = j.modal;
var fresh = tmp.querySelector('#anNewCard');
if (!fresh) { return; }
old.parentNode.replaceChild(fresh, old);
setOpen(true);
});
}
return r.text().then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var fresh = doc.getElementById('anListHost');
var cur = document.getElementById('anListHost');
if (fresh && cur) { cur.innerHTML = fresh.innerHTML; }
else { window.location.reload(); return; }
var c = card();
if (c) { c.hidden = true; }
var b = document.getElementById('anNewBtn');
if (b) { b.setAttribute('aria-expanded', 'false'); }
});
}).catch(function () {
if (s) { s.disabled = false; s.textContent = '确定'; }
form.submit();
}).then(function () {
if (s) { s.disabled = false; s.textContent = '确定'; }
});
}
document.addEventListener('click', function (ev) {
var t = ev.target;
if (!t || !t.closest || !t.closest('#anNewSubmit')) { return; }
ev.preventDefault();
save();
});
document.addEventListener('submit', function (ev) {
var f = ev.target;
if (!f || f.id !== 'anForm') { return; }
ev.preventDefault();
save();
});
})();
</script>
<?php endif; ?>
<?php /* 🔴 v41:列表内容包一层 `#anListHost` —— 弹框里保存成功后前端只换这一块。 */ ?>
<div id="anListHost">
<div class="card card-pad">
<h2 class="cinema-h2">接下来的日子</h2>
<?php if (!$upcoming): ?>
<p class="empty-hint">未来 60 天里没有纪念日。</p>
<?php else: ?>
<ul class="ann-up">
<?php foreach ($upcoming as $u): ?>
<li class="ann-up-item">
<span class="ann-days"><?= (int)$u['days'] === 0 ? '今天' : ((int)$u['days'] . ' 天后') ?></span>
<span class="ann-date"><?= e(substr((string)$u['date'], 5)) ?></span>
<span class="ann-title"><?= e((string)$u['title']) ?></span>
<?php if ($u['age'] !== null): ?><span class="small muted"><?= (int)$u['age'] ?> 岁</span><?php endif; ?>
<?= $kindBadge($u) ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php /* ==================== ② 手动添加的(在上,用户第 9 条)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
我自己添加的(<?= count($custom) ?>)
<span class="hint">只有这些能改能删</span>
</h2>
<?php if (!$custom): ?>
<p class="empty-hint">
还没有自己添加的纪念日。
<?php if ($canWrite): ?>
结婚纪念日、忌日、相识的日子…点右上角「新建纪念日」加一条。
<?php endif; ?>
</p>
<?php else: ?>
<?php $renderTable($custom, true); ?>
<?php endif; ?>
</div>
<?php /* ==================== ③ 来自名册的生日(在下)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
来自名册的(<?= count($derived) ?>)
<span class="hint">实时读出来的,改生日这里跟着变</span>
</h2>
<?php if (!$derived): ?>
<p class="empty-hint">
名册里还没有人填过生日。到「名册 → 编辑成员」把生日填上,这里就会自动出现。
</p>
<?php else: ?>
<?php $renderTable($derived, false); ?>
<p class="tip" style="margin-top:10px">
这一块是**实时派生**的 —— 改生日,这里跟着变,不用重复维护。
不需要在这里新建,也没法单独删(删掉生日就等于删了它)。
</p>
<?php endif; ?>
</div>
</div><?php /* /#anListHost */ ?>