diff --git a/app/HomeCards.php b/app/HomeCards.php
index 64e8af9..558e569 100644
--- a/app/HomeCards.php
+++ b/app/HomeCards.php
@@ -108,11 +108,24 @@ final class HomeCards
'name' => '纪念日', 'desc' => '接下来 60 天的纪念日与生日',
'w' => 4, 'h' => 4, 'route' => 'days', 'more' => '全部纪念日', 'n' => 0,
'mod' => 'days',
+ /* 🔴 v81(用户 2026-10-22):「如果是纪念日,就有一个"新增纪念日"。」
+ ⇒ 首页卡片右上角直接开新建弹框(与纪念日页**同一张** `days/form.php`)。
+ `add_modal` = 那张弹框在首页里的容器 id,`home.php` 拼成
+ `data-fb-add-open="#anNewCard"`;掉进 `assets/js/add_modal.js` 的通用逻辑。
+ 🔴 这个 id 必须与 `days/form.php` 里真实的容器 id **逐字一致**:
+ 第一版我按"an + AddCard"的规律写成 `anAddCard`,而实际是 `anNewCard`
+ ⇒ 按钮指向了一个不存在的元素 ⇒ **点下去毫无反应**(探针⑥当场抓到)。
+ 这正是"点了没反应最难查"的那一类:属性在、DOM 也没报错。 */
+ 'add' => '新建纪念日', 'add_modal' => 'anNewCard',
],
'movies' => [
'name' => '观影', 'desc' => '最近看过的片子',
'w' => 4, 'h' => 4, 'route' => 'movies', 'more' => '观影记录', 'n' => 0,
'mod' => 'movies',
+ /* 🔴 v81(同一条):「比如电影观影,它有一个"新增电影"的功能。」
+ ⚠️ 文案按用户说的「新增电影」,但弹框标题仍是模块里的「记一场电影」——
+ 不为了统一而改模块文案(那是模块自己的措辞,用户没要求动)。 */
+ 'add' => '新增电影', 'add_modal' => 'mvAddCard',
],
'books' => [
'name' => '读书', 'desc' => '在读与最近加进书架的书',
diff --git a/assets/js/add_modal.js b/assets/js/add_modal.js
new file mode 100644
index 0000000..3e421ba
--- /dev/null
+++ b/assets/js/add_modal.js
@@ -0,0 +1,117 @@
+/* ============================================================================
+ 通用「新建弹框」开关(v81)
+ ----------------------------------------------------------------------------
+ 用户原话(2026-10-22):
+ 「首页上的其他卡片也应该有这些基本功能。比如电影观影,它有一个"新增电影"的
+ 功能;如果是纪念日,就有一个"新增纪念日"。实际上,就是把原来模块里弹的那个框,
+ 现在拿到这边来。目的就是为了在首页上就能快速完成一定工作,而不用进到模块里面。」
+
+ v78 给「近期任务」写过一份 `task_add.js`(只能管那一张弹框)。这一批要把
+ 观影、纪念日也搬上来 ⇒ 三份几乎一样的脚本 = 三处迟早不同步。所以抽这一份**通用的**:
+ 每张弹框只需声明自己的「容器 id / 开按钮 / 关按钮」三个名字。
+
+ 用法(在弹框模板里给容器加两个属性即可,不需要改 JS):
+
+ 或者更省事:容器上加 `data-fb-add-modal`,触发器上加
+ `data-fb-add-open="#<容器id>"`、`data-fb-add-close` —— 全站约定见下。
+
+ 行为(与站内其它浮层一致,v78 那套原样保留):
+ · 点触发器开、点 ✕/取消 关、点**遮罩**关、点面板内部**不**关、Esc 关;
+ · Esc 时若 antd 的日期面板开着,**先让 antd 收自己的**(否则一次 Esc 关两层,用户懵);
+ · 打开那一刻才升级日期控件 —— 弹框初始 `hidden`,antd 面板拿到 0 尺寸会定位到
+ 屏幕左上角(task_add.js 与 tasks/show.php 都为此专门 boot 过,同一条坑)。
+
+ ⚠️ 全站加载,页面里没有带 `data-fb-add-modal` 的容器时**什么都不做**(几行判断)。
+ ⚠️ 幂等闸门 `window.__fbAddModalBound`:脚本可能被重复引入(AJAX 换正文、模板重复),
+ 不加会绑两遍 ⇒ 点一次开两次、关两次。
+ ⚠️ 为什么不用内联 script:内联脚本在 AJAX 换正文时**不会被重跑**(v42/v47 连着栽过)。
+ ============================================================================ */
+(function () {
+ 'use strict';
+ if (window.__fbAddModalBound) { return; }
+ window.__fbAddModalBound = true;
+
+ /* 容器:`data-fb-add-modal` 自带 id 也算(省去写两遍) */
+ function containers() {
+ return Array.prototype.slice.call(
+ document.querySelectorAll('[data-fb-add-modal]'));
+ }
+
+ function antdOpen() {
+ var d = document.querySelector('.ant-picker-dropdown');
+ return !!(d && !d.classList.contains('ant-picker-dropdown-hidden'));
+ }
+
+ /* 打开时要做的"补动作":日期控件必须等可见之后再升级(hidden 时宽高为 0)。
+ 两步都要:先绑自建下拉(兜底),再挂 antd(正主);两个函数都自判幂等。 */
+ function wake(c) {
+ if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
+ if (window.fbBootAntd) { window.fbBootAntd(); }
+ /* 焦点落到第一颗输入框:键盘用户不用先 Tab 一遍 */
+ var f = c.querySelector('input:not([type=hidden]), textarea, select');
+ if (f && !f.disabled) { try { f.focus(); } catch (e) {} }
+ }
+
+ function show(c, on) {
+ if (!c) { return; }
+ c.hidden = !on;
+ /* 触发器的 aria-expanded 跟着走(有就更新,没有不报错) */
+ var sel = c.getAttribute('data-fb-modal-open');
+ if (sel) {
+ Array.prototype.forEach.call(document.querySelectorAll(sel), function (b) {
+ b.setAttribute('aria-expanded', on ? 'true' : 'false');
+ });
+ }
+ if (on) { wake(c); }
+ }
+
+ /* 由点击位置反推"该开/该关哪一张":
+ · 触发器上写 `data-fb-add-open="#id"`(显式指定容器);
+ · 关闭按钮/遮罩则就近找祖先里的 `[data-fb-add-modal]`。 */
+ function targetFor(t) {
+ if (t.closest('[data-fb-add-close]')) {
+ return { c: t.closest('[data-fb-add-modal]'), on: false };
+ }
+ var opener = t.closest('[data-fb-add-open]');
+ if (opener) {
+ var sel = opener.getAttribute('data-fb-add-open') || '';
+ var c = sel ? document.querySelector(sel) : null;
+ return { c: c, on: true };
+ }
+ return null;
+ }
+
+ document.addEventListener('click', function (e) {
+ var t = e.target;
+ if (!t || !t.closest) { return; }
+ var hit = targetFor(t);
+ if (hit) {
+ if (hit.c) { e.preventDefault(); }
+ show(hit.c, hit.on);
+ return;
+ }
+ /* 点遮罩关、点面板内不关 —— 容器本身就是遮罩层,所以只认 === */
+ if (t.hasAttribute && t.hasAttribute('data-fb-add-modal')) {
+ show(t, false);
+ }
+ });
+
+ document.addEventListener('keydown', function (e) {
+ if (e.key !== 'Escape') { return; }
+ var open = containers().filter(function (c) { return !c.hidden; });
+ if (!open.length) { return; }
+ if (antdOpen()) { return; } // 让 antd 先收它自己的面板
+ show(open[open.length - 1], false);
+ });
+
+ /* 供其它脚本(提交成功后就地插入回包等)主动开合:
+ window.fbAddModal.open('#id') / .close('#id') */
+ window.fbAddModal = {
+ open: function (sel) { show(document.querySelector(sel), true); },
+ close: function (sel) { show(document.querySelector(sel), false); }
+ };
+})();
\ No newline at end of file
diff --git a/index.php b/index.php
index f15df3d..e01230e 100644
--- a/index.php
+++ b/index.php
@@ -935,6 +935,40 @@ switch ($r) {
}
}
+ /* 🔴 v81(用户 2026-10-22):「首页上的其他卡片也应该有这些基本功能。
+ 比如电影观影,它有一个"新增电影"的功能;如果是纪念日,就有一个"新增纪念日"。
+ 实际上,就是把原来模块里弹的那个框,现在拿到这边来。」
+ ⇒ 首页要能内嵌 `movies/form.php` 与 `days/form.php`,就要给它们准备好数据。
+ **判据与 `$tkAdd` 完全一致**:卡片真摆在首页 + 账号可写,才准备;
+ 否则关掉卡片之后还白白跑 `Person::roster()` / `Cinema::all()`。
+
+ ⚠️ 这里的 key 必须与 `HomeCards::TYPES` 里的 `add_modal` **一一对应**
+ (`movies` ⇄ `mvAddCard`、`days` ⇄ `anAddCard`):
+ `home.php` 按 key 走不同分支 require 不同模板,写错一个就是
+ "按钮点开了一张不存在的弹框"(点了没反应,最难查的那种)。
+ ⚠️ 别把这里扩成"遍历所有卡片的 add"就完事:每张卡要的数据不同,
+ 新增一张要新建弹框的卡片时必须回来加一个分支(宁可多写三行,
+ 也不要写一个"猜字段名"的通用装配器)。 */
+ $homeAddModals = [];
+ if (Auth::canWrite()) {
+ foreach ($homeCards as $hcRow) {
+ $hcKey = (string)($hcRow['k'] ?? '');
+ if ($hcKey === 'movies' && !isset($homeAddModals['movies'])) {
+ // 与 movies/index.php 内嵌时**同一组**数据(同一个表单要同一份料)
+ $homeAddModals['movies'] = [
+ 'people' => Person::roster($familyId, $me, 'member', ''),
+ 'cinemas' => Cinema::all($familyId),
+ ];
+ } elseif ($hcKey === 'days' && !isset($homeAddModals['days'])) {
+ // 与 days/index.php 内嵌时同一组:分类清单(名称 + 图标)+ 名册
+ $homeAddModals['days'] = [
+ 'kindsFull' => Anniversary::kindsFull($familyId),
+ 'people' => Person::roster($familyId, $me, 'member', ''),
+ ];
+ }
+ }
+ }
+
View::render('home', [
'title' => '首页',
'family' => Family::find($familyId),
@@ -970,6 +1004,8 @@ switch ($r) {
'cardData' => $cardData,
// v78:首页「新建任务」弹框的数据(卡片没摆/只读账号时为 null)
'tkAdd' => $tkAdd,
+ // v81:首页「新增电影」「新建纪念日」弹框的数据(同上,卡片没摆就没有)
+ 'homeAddModals' => $homeAddModals,
/* 🔴 v71c(用户 2026-10-21):「通知消息这个,也是首页的卡片,可以调整
顺序和大小。」
⇒ 通知**不再是**这里单独传的两个变量(`notifs` / `notifUnread`),
@@ -3327,6 +3363,15 @@ switch ($r) {
);
clear_old();
flash('ok', '已记下这场电影。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
+ /* 🔴 v81(用户 2026-10-22):「把原来模块里弹的那个框,现在拿到这边来。
+ 目的就是为了在首页上就能快速完成一定工作,而不用进到模块里面。」
+ ⇒ 从**首页**那张弹框提交的,回首页(卡片上立刻能翻到刚记的这部)。
+ ⚠️ 默认仍是「跳详情页」—— 从观影模块新建时,用户接着就要补
+ 评价、加照片、传票根,跳详情页才对;**只有首页来的才改道**。
+ ⚠️ 白名单取值,不把 POST 字符串直接当跳转地址(开放重定向)。 */
+ if ((string)($_POST['back'] ?? '') === 'home') {
+ redirect('home');
+ }
redirect('movies/show', ['id' => $newId]);
}
break;
@@ -5005,6 +5050,14 @@ $want = array_keys($want);
'person_ids' => array_map('intval', $pids),
], $id);
flash('ok', $id > 0 ? '纪念日已保存。' : '纪念日已添加。');
+ /* 🔴 v81(用户 2026-10-22):「把原来模块里弹的那个框,现在拿到这边来。
+ 目的就是为了在首页上就能快速完成一定工作,而不用进到模块里面。」
+ ⇒ 首页那张弹框提交时带 `back=home`,回首页(卡片上立刻能看到新加的)。
+ ⚠️ 白名单取值,绝不把 POST 的字符串直接当跳转地址(开放重定向)。
+ ⚠️ 编辑不从首页走(首页只有"新建"入口),所以 `$id > 0` 时不认这个参数。 */
+ if ($id <= 0 && (string)($_POST['back'] ?? '') === 'home') {
+ redirect('home');
+ }
redirect('days');
break;
diff --git a/templates/days/form.php b/templates/days/form.php
index afdc000..a6cd0f9 100644
--- a/templates/days/form.php
+++ b/templates/days/form.php
@@ -56,6 +56,11 @@ $anModalOnly = !empty($anModalOnly);
$anOpen = !empty($anOpen);
$anEmbed = !empty($anEmbed);
$anShell = !isset($anShell) ? !$anEmbed : (bool)$anShell;
+/* 🔴 v81:首页内嵌时挂通用开关属性(`assets/js/add_modal.js`)。
+ ⚠️ **只在首页设**(`$fbAddModalAttr` 由 `templates/home.php` 传):
+ 纪念日列表页有一套自己的内联开关脚本(还要管"编辑后替换卡片"那套 AJAX),
+ 再挂通用属性就是两套同时绑 —— 按需挂,模块页行为不变。 */
+$fbAdd = !empty($fbAddModalAttr);
$backUrl = url('days');
?>
@@ -79,12 +84,15 @@ $backUrl = url('days');
if (!$anModalOnly) {
echo ' data-an-standalone="1" data-an-back="' . e($backUrl) . '"';
}
+ if ($fbAdd) {
+ echo ' data-fb-add-modal';
+ }
} ?>>
= $editing ? '编辑纪念日' : '新建纪念日' ?>
-
+
@@ -93,7 +101,9 @@ $backUrl = url('days');
= csrf_field() ?>
-
+
+
@@ -204,7 +214,7 @@ $backUrl = url('days');
diff --git a/templates/home.php b/templates/home.php
index aed111c..6c7396e 100644
--- a/templates/home.php
+++ b/templates/home.php
@@ -122,14 +122,29 @@
-
+ ⇒ 卡片登记表里登记了 `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 已上线,不动它以免回归);所以这里**按登记表决定挂哪个属性**,
+ 不是一刀切换掉 —— 两种写法并存但各自有唯一来源。 */ ?>
+
+
+
+ data-fb-add-open="#= e($hcAddModal) ?>" aria-haspopup="dialog" aria-expanded="false"
+
+ data-tk-add-open
+
+ = $hcColor !== '' ? ' style="--hd-c: ' . e($hcColor) . ';"' : '' ?>>= e($hcAdd) ?>
@@ -230,3 +245,49 @@
unset($tkAddBack, $tkAddBoards, $tkAddCats, $tkAddPeople, $tkAdd);
?>
+
+
+
+ $hcAddData): ?>
+
+
+
diff --git a/templates/layout.php b/templates/layout.php
index 00f6b61..fdfbe91 100644
--- a/templates/layout.php
+++ b/templates/layout.php
@@ -258,6 +258,13 @@ require __DIR__ . '/_entry_popup.php'; ?>
+
+
-