Files
FamilyBook/templates/home.php
T
laoyang fd90e33d94 v81:首页卡片「新增电影」「新建纪念日」弹框(不离开首页就把事办了)
用户 2026-10-22:
「首页上的其他卡片也应该有这些基本功能。比如电影观影,它有一个"新增电影"的功能;
  如果是纪念日,就有一个"新增纪念日"。实际上,就是把原来模块里弹的那个框,现在拿到
  这边来。目的就是为了在首页上就能快速完成一定工作,而不用进到模块里面。」

做法:复用模块页**同一份**表单,不另写。
- app/HomeCards.php:TYPES 每张卡多登记 add(按钮文案)/ add_modal(弹框容器 id)。
- templates/home.php:按登记表挂 data-fb-add-open="#<id>"(没登记的仍用 v78 的
  data-tk-add-open,两种写法并存但各有唯一来源);页尾 require 模块的表单模板。
- assets/js/add_modal.js(新):通用开关,只认 [data-fb-add-modal] /
  data-fb-add-open / data-fb-add-close;幂等闸门;Esc 先让 antd 收面板;
  打开那一刻才 boot 日期控件(hidden 时尺寸为 0,antd 会定位到屏幕左上角)。
- templates/{movies,days}/form.php:只在首页内嵌时($fbAddModalAttr)挂通用属性
  并输出 back=home,模块页行为一字不变。
- index.php:days/save 与 movies/create 加 back=home 白名单(取 POST 再比字面量,
  不把 POST 当跳转地址)。

过程中修掉两个真问题:
- 登记表第一版写 anAddCard,而 days/form.php 实际是 anNewCard ⇒ 按钮属性写对了、
  DOM 也不报错,点下去毫无反应。已加「按钮指向的容器真的存在」判据与部署静态
  断言里的交叉校验(拿登记表 id 去 templates/ 里逐个找 id="...")。
- 探针判「提交后回首页」原本读 pg.url,而 wait_for_load_state(networkidle) 在
  点击那一刻本就 idle 时立刻返回 ⇒ 读到提交前的地址,恒绿。改成拦那次 POST 的
  响应看 (状态码, Location),拆成 ⑭a 提交真发生 / ⑭b 302 指向 home / ⑭c 浏览器停在 home。

验证:正向探针 23/0 · 反向验证 14/0(七注入各自变红)· e2e 1572/0 · 几何 428/0。
2026-10-22 22:05:17 +08:00

294 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
/**
* 首页 —— 卡片网格(v62 用户 2026-10-18 第 3 条)。
*
* 用户原话:「今天,我们正式引入卡片的概念,在首页上,概述是卡片,日程是卡片,
* 近期任务是卡片,往年的今天也是卡片,纪念日,要有一个卡片,电影、日程、
* 读书,都要有自己的卡片。……卡片的规格,我们可以定义一下,例如,把宽度
* 定义为 8,高度可以是 2、4、6、8,这样。如果是 2x2,那么就可以一排放 4 个,
* 如果是 2x8,那就是一行放一个,类似于这样的定义。」
*
* ────────────────────────────────────────────────────────────────
* 结构变了,但**内容一字未改**:原来的三块(信息面板 / 日程+任务 / 往年的今天)
* 现在各自是一张卡片,内容块原样搬进 `templates/home/_card_*.php`。
*
* 尺寸口径(与 app.css 的 `.hc-*`、设置页文字完全一致):
* · 一排 **8 格**;宽 2 / 4 / 6 / 8 → 一排 4 张 / 2 张 / 一大一小 / 独占一排;
* · 高 2 / 4 / 6 / 8 行(一行 76px + 10px 间距)。
* · 排版完全由 CSS 网格决定:**卡片顺序 = 数组顺序**,不需要任何浮动/清浮。
*
* ⚠️ 别在模板里自己算"该多宽":宽高由 `$homeCards`(用户设置)给,
* 模板只负责把类名拼对(`hc-w4 hc-h8`)。
*
* 🔴 v71c(用户 2026-10-21)推翻了一条旧约束:「通知消息这个,也是首页的卡片,
* 可以调整顺序和大小。」—— 它原来在本文件里是一块**网格之前**的写死区块
* (v30 第 5 条当时还没有卡片体系,只能用"排在网格前"保证它在「往年的今天」上面)。
* 现在它是一张**正规卡片**(`_card_notif.php`):位置/宽高/开不开都由用户定。
* ⇒ 这条"必须在网格前"的约束**到此为止**,别再按它往网格上加写死区块。
*
* @var array $homeCards HomeCards::layout() 的结果(每项 k/w/h/n)
* @var array $cardData HomeCards::data() 取的那几张卡片的额外数据
*/
?>
<?php /* 用户 2026-10-03 第 3 条:
① 标题下显示**个人 slogan**;
② 右侧那三块统计(与我有关 / 名册总数 / 亲属连线)**去掉**;
③ 「我的资料 / 我的家谱」保留,但从文字按钮换成**图标按钮**;
④ 「名册」按钮去掉 —— 它与顶部导航条里的「名册」重复。
v62 追加第五个图标按钮:「首页卡片」布局设置(与这排图标同一套做法)。 */ ?>
<section class="hero">
<img class="hero-logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
<div class="hero-main">
<h1><?= e(\App\Auth::homeTitle()) ?></h1>
<?php $heroSlogan = trim((string)($mySlogan ?? '')) !== ''
? (string)$mySlogan
: trim((string)($family['motto'] ?? '')); ?>
<?php if ($heroSlogan !== ''): ?>
<p class="hero-slogan"><?= e($heroSlogan) ?></p>
<?php endif; ?>
</div>
<?php /* ⚠️ 这两个链接必须带上真实的 person id:曾经 layout.php 里写
`$me = Auth::check()` 把模板里的 $me 覆盖成布尔值,链接就变成 id=0
("补充亲属关系"点进去是空白页)。所以这里显式用 $meId。 */ ?>
<div class="hero-actions">
<a class="icon-btn" href="<?= e(url('home/cards')) ?>"
title="首页卡片(摆哪几张、多大)" aria-label="首页卡片">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="2.4" y="2.4" width="6" height="6" rx="1.2"/>
<rect x="11.6" y="2.4" width="6" height="6" rx="1.2"/>
<rect x="2.4" y="11.6" width="6" height="6" rx="1.2"/>
<rect x="11.6" y="11.6" width="6" height="6" rx="1.2"/>
</svg>
</a>
<a class="icon-btn" href="<?= e(url('people/show', ['id' => (int)$meId])) ?>"
title="我的资料" aria-label="我的资料">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="10" cy="7" r="3.4"/><path d="M3.6 17c0-3.3 2.9-5.4 6.4-5.4s6.4 2.1 6.4 5.4"/>
</svg>
</a>
<a class="icon-btn" href="<?= e(url('kin')) ?>"
title="我的家谱" aria-label="我的家谱">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="7.4" y="2.2" width="5.2" height="4.2" rx="1"/>
<rect x="2.2" y="13.6" width="5.2" height="4.2" rx="1"/>
<rect x="12.6" y="13.6" width="5.2" height="4.2" rx="1"/>
<path d="M10 6.4v3.4M4.8 13.6v-1.9h10.4v1.9M4.8 11.7h10.4"/>
</svg>
</a>
</div>
</section>
<?php /* ==================== 卡片网格(v62)====================
⚠️ `require` 进来的每个 `_card_*.php` 与这里**同一作用域**(共享 $tasks / $todayEvents
/ $cardData …):这是刻意的 —— 卡片的取数与首页的取数必须同源,
各自再查一遍就会出现"数字和列表对不上"那类老问题。 */ ?>
<section class="section">
<?php if (empty($homeCards)): ?>
<div class="card card-pad empty">
<b>首页卡片都被关掉了</b>
到 <a href="<?= e(url('home/cards')) ?>">首页卡片</a> 里重新勾几张出来。
</div>
<?php else: ?>
<div class="hc-grid" id="homeCards">
<?php foreach ($homeCards as $card): ?>
<?php
$hcKey = (string)($card['k'] ?? '');
if (!\App\HomeCards::exists($hcKey)) {
continue;
}
$hcDef = \App\HomeCards::TYPES[$hcKey];
$hcTpl = __DIR__ . '/home/_card_' . $hcKey . '.php';
if (!is_file($hcTpl)) {
continue; // 注册了但还没写模板:跳过,不要白屏
}
$hcMore = (string)($hcDef['route'] ?? '') !== '' ? url((string)$hcDef['route']) : '';
/* 🔴 v63(用户 2026-10-19 第 1 条):「卡片右上角的按钮,应该跟这个卡片
来自于什么模块的主题色一样才对。」
⇒ 颜色从**卡片登记表**取(`HomeCards::module()`),不再在这里硬编码白名单。
日程卡来自 calendar、任务卡来自 tasks、观影来自 movies…… 一张不漏。 */
$hcMod = \App\HomeCards::module($hcKey);
$hcColor = $hcMod !== '' ? \App\Module::color($hcMod, (int)\App\Auth::familyId()) : '';
?>
<section class="card hc-card hc-w<?= (int)$card['w'] ?> hc-h<?= (int)$card['h'] ?>"
data-hc="<?= e($hcKey) ?>" id="homeCard-<?= e($hcKey) ?>">
<div class="hc-head">
<h2 class="cinema-h2"><?= e((string)$hcDef['name']) ?></h2>
<?php if ($hcMore !== ''): ?>
<a class="btn btn-sm hd-more<?= $hcColor === '' ? ' btn-ghost' : '' ?>"
href="<?= e($hcMore) ?>"<?= $hcColor !== '' ? ' style="--hd-c: ' . e($hcColor) . ';"' : '' ?>><?= e((string)$hcDef['more']) ?></a>
<?php endif; ?>
<?php /* 🔴 v78(用户 2026-10-22):「在全部任务旁边,放一个新建任务的功能,
其实就是把任务中的新建和编辑功能,搬过来,用弹出框来处理。」
⇒ 卡片登记表里登记了 `add` 的,就在「更多」**右边**再多一颗 ——
同一套 `.hd-more` 配色,所以它是那个模块的主题色,不会抢眼。
🔴 v81(用户同日):「首页上的其他卡片也应该有这些基本功能。
比如电影观影,它有一个"新增电影"的功能;如果是纪念日,就有一个
"新增纪念日"。实际上,就是把原来模块里弹的那个框,现在拿到这边来。」
⇒ 属性改成**通用**的 `data-fb-add-open="#<弹框容器 id>"`(v81 的
`assets/js/add_modal.js` 认这个),弹框 id 由登记表 `add_modal` 给出。
⚠️ 任务的弹框仍由老脚本 `task_add.js` + `data-tk-add-open` 管
(v78 已上线,不动它以免回归);所以这里**按登记表决定挂哪个属性**,
不是一刀切换掉 —— 两种写法并存但各自有唯一来源。 */ ?>
<?php
$hcAdd = (string)($hcDef['add'] ?? '');
$hcAddModal = (string)($hcDef['add_modal'] ?? '');
?>
<?php if ($hcAdd !== '' && \App\Auth::canWrite()): ?>
<button type="button" class="btn btn-sm hd-more<?= $hcColor === '' ? ' btn-ghost' : '' ?>"
<?php if ($hcAddModal !== ''): ?>
data-fb-add-open="#<?= e($hcAddModal) ?>" aria-haspopup="dialog" aria-expanded="false"
<?php else: ?>
data-tk-add-open
<?php endif; ?>
<?= $hcColor !== '' ? ' style="--hd-c: ' . e($hcColor) . ';"' : '' ?>><?= e($hcAdd) ?></button>
<?php endif; ?>
</div>
<?php require $hcTpl; ?>
</section>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php /* ==================== 首页任务复选框的表单与脚本(v24 第 25 批 用户第 6 条)====================
「在任何地方点复选框,这个任务就完成了」—— 首页也算"任何地方"。
与任务看板同一套做法:**表单全页一份**,行上只放 data-tk-done。
⚠️ 改完**不摘掉这一行**:用户点完正想看自己刚完成什么,立刻消失会让人怀疑
"是不是点到删除了"。刷新时由服务端决定它还在不在。
⚠️ 这一段是**页面级**的(不属于某张卡片):任务卡片被关掉后它也跟着没用,
但留着无害 —— 别为了"省几行"把它挪进卡片模板里,那样卡片一换模板就丢功能。 */ ?>
<?php if (!empty($tasks) && \App\Auth::canWrite()): ?>
<form method="post" action="<?= e(url('tasks/status')) ?>" id="taskDoneForm" class="hidden-form">
<?= csrf_field() ?>
<input type="hidden" name="id" id="doneId">
<input type="hidden" name="status" id="doneStatus">
<input type="hidden" name="back" id="doneBack" value="home">
</form>
<script>
(function () {
var form = document.getElementById('taskDoneForm');
if (!form) { return; }
var staticWord = {todo: '待办', doing: '进行中', done: '已完成'};
function say(msg, ok) {
var host = document.querySelector('.flash');
if (!host) {
host = document.createElement('div');
host.className = 'flash';
var main = document.querySelector('main') || document.body;
main.insertBefore(host, main.firstChild);
}
var el = document.createElement('div');
el.className = 'flash-item' + (ok ? '' : ' err');
el.setAttribute('role', 'status');
el.textContent = msg;
host.insertBefore(el, host.firstChild);
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 3200);
}
Array.prototype.forEach.call(document.querySelectorAll('.tk-check[data-tk-done]'), function (b) {
b.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var now = b.getAttribute('data-tk-status') || 'todo';
var next = (now === 'done') ? 'todo' : 'done';
document.getElementById('doneId').value = b.getAttribute('data-tk-done');
document.getElementById('doneStatus').value = next;
document.getElementById('doneBack').value = b.getAttribute('data-tk-back') || 'home';
if (!window.fetch || !window.FormData) { form.submit(); return; }
b.disabled = true;
fetch(form.getAttribute('action'), {
method: 'POST', body: new FormData(form), credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
b.disabled = false;
if (!d || !d.ok) { form.submit(); return; }
b.setAttribute('data-tk-status', d.status);
b.classList.toggle('is-done', d.status === 'done');
b.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false');
var row = b.closest('[data-task-row]');
var badge = row ? row.querySelector('.kb-st') : null;
if (badge) {
badge.className = 'kb-st is-' + d.status + ' is-static';
badge.textContent = staticWord[d.status] || d.word;
}
say(d.msg || '已更新。', true);
}).catch(function () { b.disabled = false; form.submit(); });
});
});
})();
</script>
<?php endif; ?>
<?php /* ==================== 「新建任务」弹框(v78 用户 2026-10-22)====================
用户原话:「在全部任务旁边,放一个新建任务的功能,其实就是把任务中的新建和编辑功能,
搬过来,用弹出框来处理。」
⚠️ 与任务页(`templates/tasks/index.php`)**共用同一份** `tasks/_add_modal.php` ——
两处各写一张表单,字段必然有一天不同步(`_edit_form.php` 头部记着同一条教训)。
⚠️ `$tkAdd` 只在「近期任务」卡片真的摆在首页、且当前账号可写时才由路由准备
(见 index.php 的 home 分支):卡片被关掉就没有入口,也就不该白跑三条查询。
⚠️ 新建完回**首页**(`back=home`),卡片上立刻能看到刚建的那条 ——
用户要的是"不离开首页就把事办了"。 */ ?>
<?php if (!empty($tkAdd)): ?>
<?php
$tkAddBack = 'home';
$tkAddBoards = (array)($tkAdd['boards'] ?? []);
$tkAddCats = (array)($tkAdd['categories'] ?? []);
$tkAddPeople = (array)($tkAdd['people'] ?? []);
require __DIR__ . '/tasks/_add_modal.php';
unset($tkAddBack, $tkAddBoards, $tkAddCats, $tkAddPeople, $tkAdd);
?>
<?php endif; ?>
<?php /* ==================== 「新增电影」「新建纪念日」弹框(v81 用户 2026-10-22)============
用户原话:「首页上的其他卡片也应该有这些基本功能。比如电影观影,它有一个"新增电影"
的功能;如果是纪念日,就有一个"新增纪念日"。实际上,就是把原来模块里弹的那个框,
现在拿到这边来。目的就是为了在首页上就能快速完成一定工作,而不用进到模块里面。」
⚠️ 与模块页**共用同一份模板**(`movies/form.php` / `days/form.php`),不另写表单:
字段、校验、服务端契约、附件上传全部同一套,永远不会不同步。
⚠️ 三件事必须一起给,否则这张弹框"长出来了但用不了":
① `$fbAddModalAttr = true` —— 让模板挂 `data-fb-add-modal`(通用开关认它);
② 模板需要的**数据**(名册 / 影院 / 分类)由路由按"卡片在不在"准备,见 index.php;
③ 表单里的 `back=home`(模板按 `$fbAddModalAttr` 自动输出)—— 提交完回首页。
⚠️ 只在对应卡片**真的摆在首页**且账号可写时才渲染:卡片被关掉就没有入口,
不该白跑 People::roster / Cinema::all 这些查询。 */ ?>
<?php if (!empty($homeAddModals) && \App\Auth::canWrite()): ?>
<?php foreach ((array)$homeAddModals as $hcAddKey => $hcAddData): ?>
<?php
$fbAddModalAttr = true; // 通用开关属性(见上面第 ① 条)
/* 每个片段自己兜底变量:模板片段不许偷宿主的变量(本项目重复踩过的坑)。 */
$m = null; $errors = [];
if ($hcAddKey === 'movies') {
$people = (array)($hcAddData['people'] ?? []);
$cinemas = (array)($hcAddData['cinemas'] ?? []);
$mvModalOnly = true; // 不输出 page-head / 设置菜单
$mvModalForce = true; // 强制弹框壳(新增态本来就是,写明更稳)
$mvOpen = false; // 初始关闭,点卡片按钮才开
require __DIR__ . '/movies/form.php';
unset($mvModalOnly, $mvModalForce, $mvOpen, $people, $cinemas, $m, $errors);
} elseif ($hcAddKey === 'days') {
$anModalOnly = true; $anOpen = false; $anModal = true;
$anEmbed = false; $anShell = true;
/* ⚠️ 这三个必须逐个显式给:`days/form.php` 自己会 `$kindsFull ?? 查库` 兜底,
但**兜底会多跑一次查询**,而且与模块页那份不一定同源。
这里直接给它容器已经查好的那份(路由里按"卡片在不在"准备)。 */
$editing = null;
$people = (array)($hcAddData['people'] ?? []);
$kindsFull = (array)($hcAddData['kindsFull'] ?? []);
$kinds = array_column($kindsFull, 'name');
require __DIR__ . '/days/form.php';
unset($anModalOnly, $anOpen, $anModal, $anEmbed, $anShell,
$editing, $people, $kindsFull, $kinds);
}
unset($fbAddModalAttr);
?>
<?php endforeach; ?>
<?php endif; ?>