Files
FamilyBook/templates/days/form.php
T
laoyang 26026c7250 v53:纪念日记年份算「第几个」/ 首页待办排在进行中下面 / 日程编辑浮层跨满整行 / 新增成员打标签 / 新增页一次填完称呼与备注
用户 2026-10-16 五条。

① 纪念日要记录年份,才能知道「第几个」
   用户原话:「纪念日需要记录年份,这样才能知道**是第几个纪念日**。」
   **真根因有两层**(两处一起才构成"没有年份"):
     · 表里没有:anniversaries 只有 month + day(当年当成"一年一度,年份无意义");
     · 表单有、但被丢掉:日期控件本来就带年份(<input type="date"> 是 YYYY-MM-DD),
       而拆分 JS **只取 mm/dd**(`mo.value = m[2]; dy.value = m[3];`)⇒ 想存也没存上。
   修法:anniversaries.year(SMALLINT UNSIGNED NULL)+ 升级路径 addColumn;
     表单补 hidden year、拆分 JS 一并写;Anniversary::save 收 year(**区间守卫 1900..2100**);
     occurrences() 输出 year 与 nth;列表页显示「2018 年起 · 第 9 个」。
   ⚠️ 年份**可空、且不给默认值**:老数据就是没有年份,猜一个(按创建年之类)
      会让页面算出一个**错的**"第 N 个"。NULL = 只按月日过、不显示序号 —— 诚实。
   ⚠️ 顺带修一个自相矛盾:repeat_type='once' 原来落在公历分支 ⇒ **年年都冒出来**;
      现在有年份了 ⇒ 只在那一年出现(没年份的保持老行为,不凭空丢)。
   🔴 **序号算法抽成单点来源 `Anniversary::nthFor()`** —— `occurrences()`(临近区)与
      days/index.php(我添加的那张表)**都调它**;两处各写一遍是"改一处忘一处"的温床。
      用**今天所在的年**("今年是第几个"),不是"下一次发生那年"。
   🔴🔴 **踩坑(最值钱)**:`addColumn`(ALTER TABLE)**必须排在那张表 CREATE TABLE 之后**。
      第一版放进 movie_details 那段(anniversaries 的建表更靠后)⇒ 全新安装
      `1146 Table 'fb_test.anniversaries' doesn't exist` ⇒ 整段 ensure() 抛异常 ⇒
      **install 直接"安装失败"**(POST 回 200、不自删、schema_version=None、库一片空白)。
      新建库走 CREATE TABLE、老库走 addColumn —— **两条路都要在**。

② 首页近期任务:把待办也放进来(排在进行中下面)
   用户原话:「首页的近期任务,**把待办也放进来吧,放在进行中的下面**。」
   原来只收「进行中 + **已逾期**的待办」(当年怕待办一多首页变长清单)。
   ⇒ 现在**全部**收进来、按**状态分组**(doing 在前、todo 在后);
     两组内部仍按截止时间升序(byStatus 的 ORDER BY 已排好)。
   ⚠️ 配套改统计:`count($tasks) 件在做` 会把待办也算成"在做" ⇒ 改成**按状态拆开数**
      (进行中→「件在做」、待办→「件待办」,两数相加=列表行数,仍同源)。
      「件待办」原读 stat7['todos'](**与列表不同源**,v24 踩过"数字和列表对不上")。
   ⚠️ 空态:「没有进行中的任务,也没有待办。」
   ⚠️ .hd-pane-body 本来就是 overflow-y:auto + flex:1 1 auto/min-height:0,行数变多**内部滚动**、
      卡片高度不变(v32"两块外框恒等高"那套兜住了)。

③ 日程编辑浮层「只有左侧有内容,右侧空白,好像分栏了」
   **真根因**(assets/css/app.css):日程编辑页 event.php 里,`.mv-grid`(观影式**纵向**列表)
   被嵌在**两列**的 `.form-grid` 里 ⇒ 它只占**第一列**。整页形态下看不出来(页面宽),
   但**浮层里面板只有 620px** ⇒ 两列各 286px ⇒ 所有字段挤在左边 286px、右边整列空着。
   实测:panel=620 / form-grid=588 / cols="286px 286px" / mv-grid w=286
        →修后 mv-grid w=588(95% of panel)、标题输入框 w=516、距右缘 ~2px。
   修法:`.form-grid > .mv-grid { grid-column: 1 / -1; }` —— **一行 CSS**。
   ⚠️ 不去改 event.php 里"整页保留两列、浮层用 mv-grid"那个结构判断(那是有意保留的);
      `.mv-grid` 本身就是"一整块、占满宽度"的语义,天生不该被塞进半列。
   ⚠️ 排查手法:这种"看起来像分栏"的问题**量几何比读 CSS 快** ——
      getBoundingClientRect 打出 panel/grid/cols/内层宽,一眼看出"嵌了几层 grid"。

④ 添加成员时是**打标签**,不是选分组
   用户原话:「名册中添加成员时,**不是选择分组,而是打标签**。现在已经没有分组的概念了,
   **只有标签的概念**。」
   ⇒ 新增分支的「所属分组」下拉(group_id)换成**标签输入**(复用 v51 的 #ptBlock 那套:
      候选勾选 + 自由输入,**文本框是唯一提交源**)。
   ⚠️ 只在**新增**时出现在那个位置(编辑时上面那个 #ptBlock 已在管标签,出现两块会让人以为填两遍)。
   ⚠️ **分组表结构 person_roster.group_id 保留不动**(名册"拖动归档"还在用、历史数据也在),
      只是**新增入口不再要求人选**。
   ⚠️ e2e 里 v14「新增表单**有**分组下拉」与新需求正相反 ⇒ 改成「没有 group_id + 有 name=tags」。

⑤ 添加成员页一次填完「我对他的称呼 + 我的备注」
   用户原话:「我在添加一个成员的时候,是不是可以直接在添加成员的页面里填写**我对他的称呼**
   以及**我对他的备注**,**一次就解决**?要不然还得先添加、再返回他的名册去填,有点麻烦。」
   ⇒ 新增页也出现这两栏:
     · **我对他的备注**(person_notes)—— 条件从 `$isEdit && !$isSelf` 改成 `!$isSelf`;
     · **我对他的称呼**(nicknames)—— **新加的输入框** `name="my_nick"`。
   服务端 people/create 落两表(挂**我**名下:viewer=我, target=他)。
   ⚠️ 判"表单里有没有这一栏"用 **array_key_exists**(不是判值是否为空)——
      与编辑路径同一口径:**空串是明确的"清空"语义**(Nickname::set('') 会删行)。
   ⚠️ 四处**重渲染路径**(撞名确认/校验失败/手机号重复/people/new 初始)都要带上
      myTags/tagChoices/myNick,否则一撞名或一报错,刚打的标签与刚写的称呼备注就全没了。
   ⚠️ 编辑页也补 myNick 回显(否则"新增时填了、编辑页看不见"会以为没存上)。

验证(新增 2 套件 + 更新 e2e/deploy 断言):
 · _fb_v53_ui.py(**26 条**真机断言)
 · _b53_falsify.py(**10 注入 / 10 全抓**,基线 26/0)
 · e2e **1517/0**;familybook_deploy.sh 加 v53 静态断言段(14 正向 + 1 反向)
 · 全流水线 **PUSH_RC=0**、部署 0 FAIL(含"第53批形态")
 · 线上 8 文件 md5 逐字节一致;`anniversaries.year` 已由升级路径补上(nullable smallint);
   线上实测 nthFor 4 用例全对(2018→第9个 / null→NULL / 2019→2 / 未来年→NULL);
   诊断页已删并复验 404

⚠️ 本轮踩坑(已写进 pitfalls.md / 工作日志):
 · **`addColumn` 必须在同表的 CREATE TABLE 之后**(否则全新安装 `1146` ⇒ ensure() 抛异常);
 · **探针里 `dispatchEvent(new Event('submit'))` 不会真的提交表单**
   (不导航、不发请求)⇒ 判"存没存上"必须真点提交按钮或 `requestSubmit()`;
 · **反向验证的 `restore()` 不能只是"再跑一遍 build_work()"**(指纹闸门会把它变空操作
   ⇒ 第二轮基线是脏的,26/0 掉到 14/12);恢复必须**无条件** copy2;
 · **注入锚点在源码里出现两次 ⇒ 打到别处 ⇒ 那条断言恒不红**
   (v53 给新增页也加了 `id="ptBlock"`,打偏了 v51 那条"编辑页标签块"的注入);
 · **别把两个共用 fb_test 库的套件并行跑**(互相删播种行 ⇒ 看着像"整套功能坏了");
 · **部署静态断言的锚点引号/反斜杠必须与源码一字不差**:`\[` 在 bash 双引号里是
   **字面反斜杠**、单双引号不能混、漏个 `(int)` 也搜不到 —— 一轮里连红 4 条全是这个。
2026-10-16 16:51:04 +08:00

294 lines
15 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 条;**v41 改成弹框 + 观影式排版**)
*
* 用户原话:
* · 第 13 条:「可以根据阴历和阳历来设置纪念日 …… 纪念日,需要按照阴历和阳历进行重复。」
* · v41 第 10 条:「新建纪念日,采用弹框形式,排版参考观影模块。」
*
* ⇒ 两条落实:
* ① **弹框**(`$anModal`):从列表页内嵌,初始关闭;直达 `days/new` 时以打开状态渲染;
* ② **排版**:整表单改用观影那套 `.mv-row`(左标签 62px + 右对齐 + 自动冒号,
* 控件占满剩余宽度),不再用 `.field` 堆叠。
*
* ⚠️ 重复方式这一栏是核心:阳历 / 农历 / 只此一次。选农历时下面的月/日
* 填的是**农历**的月日(不是公历换算)。
*
* @param array|null $editing 编辑时的既有记录
* @param array $people 名册
*/
/* 🔴 v41:被列表页 `require` 内嵌时必须**自己兜住变量**。
否则 `$editing` 未定义 → 每一处都吐 `Warning: Undefined variable`
且**打进 HTML 正文**(`HTTP 200`,看着"页面正常",实际满屏警告,
还把 `class="cal-modal"` 那行**切断**了 —— 实测:弹框容器因此变成
`<div class="cal-modal"<br /><b>Warning</b>…` ⇒ 前端 `getElementById` 拿不到,
"点新建没反应"。这正是全站那条"模板片段不许偷宿主变量"的同一类坑。 */
$editing = $editing ?? null;
$people = $people ?? [];
$kinds = $kinds ?? null;
$e0 = $editing ?: [];
$v = static fn(string $k, $def = '') => $e0[$k] ?? $def;
$repeat = (string)($e0['repeat_type'] ?? 'solar');
// 用户 2026-10-03 第 9 条:分类下拉的选项来自**可配置**的分类清单
$kinds = (array)($kinds ?? \App\Anniversary::PRESET_KINDS);
$curKind = (string)($e0['kind'] ?? '');
if ($curKind !== '' && !in_array($curKind, $kinds, true)) {
array_unshift($kinds, $curKind);
}
/* 🔴 v41 弹框开关(与观影/旅程同一套命名习惯):
$anModal —— 渲染成弹框壳
$anModalOnly —— 被列表页内嵌(初始关闭)
$anOpen —— 初始打开(直达 / AJAX 校验失败重画)
$anEmbed —— 只吐片段(详情页/列表页弹框 fetch 用) */
$anModal = !isset($anModal) ? true : (bool)$anModal;
$anModalOnly = !empty($anModalOnly);
$anOpen = !empty($anOpen);
$anEmbed = !empty($anEmbed);
$anShell = !isset($anShell) ? !$anEmbed : (bool)$anShell;
$backUrl = url('days');
?>
<?php if (!$anEmbed && !$anModal): ?>
<div class="page-head">
<h1><?= $editing ? '编辑纪念日' : '新建纪念日' ?></h1>
<div class="actions">
<a class="btn btn-ghost" href="<?= e($backUrl) ?>">返回</a>
</div>
</div>
<?php endif; ?>
<?php if ($anShell): ?>
<div class="<?= $anModal ? 'cal-modal' : 'card card-pad' ?>"<?php
if ($anModal) {
echo ' id="anNewCard" role="dialog" aria-modal="true" aria-label="'
. ($editing ? '编辑纪念日' : '新建纪念日') . '"' . ($anOpen ? '' : ' hidden');
/* 🔴 v42 第 9 条:标记"这是**整页直达**的弹框"(不是列表页内嵌的),
以及它该退回到哪里 —— 关闭时若无宿主容器,就跳回列表页。
`$anModalOnly` = "被列表页内嵌",那种情形由列表页的容器接管,不用跳页。 */
if (!$anModalOnly) {
echo ' data-an-standalone="1" data-an-back="' . e($backUrl) . '"';
}
} ?>>
<?php if ($anModal): ?>
<div class="cal-modal-panel mv-panel">
<div class="cal-modal-head">
<b><?= $editing ? '编辑纪念日' : '新建纪念日' ?></b>
<button type="button" class="cal-modal-x" id="anNewX" aria-label="关闭">&#10005;</button>
</div>
<?php endif; ?>
<?php endif; ?>
<form method="post" action="<?= e(url('days/save')) ?>" id="anForm">
<?= csrf_field() ?>
<?php if ($editing): ?><input type="hidden" name="id" value="<?= (int)$e0['id'] ?>"><?php endif; ?>
<?php if ($anModal && !$editing): ?>
<input type="hidden" name="back" value="days">
<?php endif; ?>
<div class="mv-form-inner">
<div class="mv-grid">
<div class="mv-row">
<span class="mv-lb">叫什么<i>*</i></span>
<input class="input" id="an-title" name="title" required
value="<?= e((string)$v('title')) ?>" placeholder="如:结婚纪念日">
</div>
<div class="mv-row">
<span class="mv-lb">按什么重复<i>*</i></span>
<select class="select" id="an-repeat" name="repeat_type">
<?php foreach (\App\Anniversary::REPEAT as $rk => $rv): ?>
<option value="<?= e($rk) ?>" <?= $repeat === $rk ? 'selected' : '' ?>><?= e($rv) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mv-row">
<span class="mv-lb">分类</span>
<select class="select" id="an-kind" name="kind">
<option value="">不分类</option>
<?php foreach ($kinds as $k): ?>
<option value="<?= e((string)$k) ?>" <?= $curKind === (string)$k ? 'selected' : '' ?>><?= e((string)$k) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php /* 🔴 v42(用户 2026-10-11 第 8 条):「新建纪念日,月日那里,直接两个字:日期,
也用我们最新的时间控件,后面的时分就不需要了,只需要日期。」
⇒ 标签改「日期」;控件换成 AntD **纯日期** DatePicker(`[data-antd-date]`)。
⚠️ 存储契约**没变**:仍然是 `month` + `day` 两个字段。
做法:一个 `<input type="date" name="an_date">` 作值载体(antd 写它),
JS 把它拆成两个 hidden 的 `month` / `day`(见下方脚本)。
这样 bind 到 antd 的 onChange → 写 an_date → 监听 change → 拆 month/day,
整条链只有一个"真值来源",不会出现两处不同步。
⚠️ `name="an_date"` 不是 `month/day` 本身 —— 它只是中间载体,
提交前脚本必把它拆好;服务端不读 an_date(days/save 读 month/day)。 */ ?>
<div class="mv-row">
<span class="mv-lb">日期<i>*</i></span>
<div class="dt-input dt-antd-single" data-antd-date="1" id="an-date-wrap">
<?php /* 🔴🔴 v43(用户 2026-10-11 第 1 条):「日期那里多了一个控件,
只保留我们最新的控件就行,之前那种控件删除。」
真根因:v42 加的那条 CSS 藏原生框的规则写的是
`.dt-input.is-antd > .dt-native { …clip… }`
—— 它认的是 `.dt-native` 这一层包装。而这里当初**没包这一层**,
原生 `<input type="date">` 是 `.dt-input` 的**直接子元素**
⇒ 选择器不命中 ⇒ AntD 挂上后,**原生日期框与 AntD 控件并排都在**
(用户看到的"多了一个控件")。
⇒ 修法:补上 `.dt-native` 包装(与 `_datetime.php` 的结构一致),
那条 CSS 立刻生效。顺带把原生框**视觉收起**(值载体留着),
并在下面补一条**兜底 CSS**(万一哪天结构又变,直接子元素也能收)。 */ ?>
<span class="dt-native">
<input type="date" class="dt-date" id="an-date" name="an_date"
value="<?= e(sprintf('%04d-%02d-%02d', (int)$v('year', (int)date('Y')), (int)$v('month', 1), (int)$v('day', 1))) ?>"
aria-label="日期">
</span>
<input type="hidden" id="an-month" name="month" value="<?= (int)$v('month', 1) ?>">
<input type="hidden" id="an-day" name="day" value="<?= (int)$v('day', 1) ?>">
<?php /* 🔴 v53(用户 2026-10-16 第 1 条):「纪念日需要记录年份,
这样才能知道**是第几个纪念日**。」
⚠️ 日期控件本来就带年份(`<input type="date">` 是 YYYY-MM-DD),
但下面那个拆分 JS 以前**只取 mm/dd、把年份丢了** ⇒ 库里没有年份。
现在补一个 hidden `year`,由同一条 JS 一起写。
⚠️ 留空 = 不记年份(`<input type="date">` 清空时 year 也清空)——
页面就不显示"第 N 个",不猜。 */ ?>
<input type="hidden" id="an-year" name="year" value="<?= (int)$v('year', 0) > 0 ? (int)$v('year', 0) : '' ?>">
</div>
</div>
<div class="mv-row">
<span class="mv-lb">与谁相关</span>
<div class="mv-fill">
<?php
$ppName = 'person_ids[]';
$ppPeople = $people;
$ppSelected = (array)($e0['person_ids'] ?? []);
if (!$ppSelected && (int)($e0['person_id'] ?? 0) > 0) {
$ppSelected = [(int)$e0['person_id']];
}
$ppMode = 'multi';
$ppOpenBtn = '+ 关联人';
$ppTitle = '这个纪念日与谁相关';
require __DIR__ . '/../_person_picker.php';
?>
</div>
</div>
<div class="mv-row">
<span class="mv-lb">农历闰月</span>
<label class="chk-inline">
<input type="checkbox" id="an-leap" name="is_leap" value="1" <?= !empty($e0['is_leap']) ? 'checked' : '' ?>>
<span>这个农历月是闰月</span>
</label>
</div>
<div class="mv-row">
<span class="mv-lb">备注</span>
<textarea class="input" id="an-note" name="note" rows="2"><?= e((string)$v('note')) ?></textarea>
</div>
</div>
</div>
<?php if ($anShell && $anModal): ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="anNewTip"></span>
<button class="btn btn-sm" type="submit" form="anForm" id="anNewSubmit">确定</button>
<button class="btn btn-ghost btn-sm" type="button" id="anNewCancel">取消</button>
</div>
</div><?php /* /.cal-modal-panel */ ?>
<?php elseif ($anEmbed): ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="anNewTip"></span>
<button class="btn btn-sm" type="submit" form="anForm" id="anNewSubmit">确定</button>
<button class="btn btn-ghost btn-sm" type="button" data-nf-close>取消</button>
</div>
<?php else: ?>
<div class="btn-row">
<button class="btn" type="submit" id="anNewSubmit"><?= $editing ? '确定' : '添加' ?></button>
<a class="btn btn-ghost" href="<?= e($backUrl) ?>">取消</a>
</div>
<?php endif; ?>
</form>
<?php if ($anShell): ?>
</div><?php /* 关掉开头那个外层(.cal-modal 或 .card) */ ?>
<?php endif; ?>
<?php if ($anModal): ?>
<script>
/* ==================== v42(用户 2026-10-11 第 9 条)====================
用户原话:「编辑纪念日,弹出后无法关闭,点击关闭和取消都不行。」
🔴 真根因:关闭逻辑(✕ / 取消 / 点遮罩 / Esc)**原来只写在 days/index.php 里**
(那是"从列表页内嵌新建弹框"那一路)。而「修改」是**跳 `days/edit` 整页**,
那一页渲染出的是同一个 `.cal-modal` 壳(带 ✕ 和「取消」按钮),
**但没有任何脚本接它们** ⇒ 按钮点了没反应、看起来"关不掉"。
⇒ 修法:把"关闭自己"这段**搬进 form.php 本体**(自包含)——
它知道自己是不是弹框形态(`$anModal`),也就能自己接自己的关闭。
⚠️ days/index.php 里那份重复的关闭逻辑要**删掉**,否则两处都绑
(浏览器里表现为"点一次关两次"或竞态),见那里的注释。
⚠️ 「取消」在整页形态下是 `<a href>`(跳回去),这里只处理**弹框形态**的按钮。 */
(function () {
'use strict';
var card = document.getElementById('anNewCard');
if (!card) { return; }
/* ⚠️🔴 只在**整页直达**形态下接关闭(`data-an-standalone="1"`)。
列表页内嵌那一路由 days/index.php 的弹框控制器负责(它还要管"打开"、
刷新列表等),这里不抢 —— 两处都绑会变成"点一次关两次"或竞态。 */
if (card.getAttribute('data-an-standalone') !== '1') { return; }
function closeSelf() {
// 整页直达形态:关掉"弹框"没有意义(它就是这个页面)→ 退回列表页最自然
var back = card.getAttribute('data-an-back') || '';
if (back) { window.location.href = back; return; }
card.hidden = true;
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
if (t.closest('#anNewX') || t.closest('#anNewCancel')) {
if (card.hidden) { return; }
e.preventDefault();
closeSelf();
}
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape' || card.hidden) { return; }
// antd 面板开着时先让它收
var d = document.querySelector('.ant-picker-dropdown');
if (d && !d.classList.contains('ant-picker-dropdown-hidden')) { return; }
closeSelf();
});
})();
/* ==================== v42 第 8 条:日期 → month/day 拆分 ====================
AntD 的 DatePicker 写的是 `#an-date`(YYYY-MM-DD),而存储契约是
`month` + `day` 两个字段。这里用**一条监听**把二者绑起来:
an_date 一变(不管是谁改的)就拆出 month/day。
⚠️ 不能靠"只在 antd onChange 里拆" —— 那样"原生输入框自己改"(antd 没加载时)
就不拆,会出现"日期看着对、存进去是 1月1日"这种最难查的错。
⚠️ 农历纪念日也可能用"月/日",年份对它无意义 —— 但这里只取 mm/dd,
年份不参与存储,所以不影响。 */
(function () {
'use strict';
var d = document.getElementById('an-date');
var mo = document.getElementById('an-month');
var dy = document.getElementById('an-day');
var yr = document.getElementById('an-year');
if (!d || !mo || !dy) { return; }
function split() {
var m = (d.value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) { return; } // 空值不动(用户清空时保留原值,不至于变成 0)
mo.value = String(parseInt(m[2], 10));
dy.value = String(parseInt(m[3], 10));
/* 🔴 v53(第 1 条):年份一起写进 hidden —— 它就是"第几个纪念日"的依据。
⚠️ 以前这里**只取 mm/dd**,年份被丢掉 ⇒ 库里根本没年份,算不出第几个。 */
if (yr) { yr.value = m[1]; }
}
d.addEventListener('change', split);
split(); // 初始也同步一次(编辑回填)
})();
</script>
<?php endif; ?>