Files
FamilyBook/templates/days/form.php
T
laoyang 9a66068c33 v62:纪念日列表修正 / 分类图标与设置菜单 / 首页卡片体系
- 纪念日列表:display:flex 从 td 移到内层 div(写在 td 上会破坏表格列算法,
  表现为关联人列与其它列高度不一致);行内列序按表头对齐;
  改完再点「新建」不再带出上次修改的数据;文案改为「与我相关」「来自名册」;
  别人记的、把我挂成关联人的条目一并显示(split() 返回 hiddenIds,
  路由层据此过滤「接下来的日子」,与两张表同一套可见性)
- 分类图标:新增 app/AnnivIcon.php(17 个行内 SVG 图标 + 同 key 的 emoji
  供日历纯文本处使用,内网无 CDN 不用 FontAwesome);去掉「看日历」按钮;
  设置按钮改为菜单,第一项进 days/settings 独立页;分类可自选图标,
  列表徽标与日历条目都按分类显示
- 首页卡片:新增 app/HomeCards.php 与 templates/home_cards.php;
  8 格宽 × 高 2/4/6/8;首页拆成 templates/home/_card_*.php 共 8 个片段;
  home/cards 可改尺寸、调顺序、开关
2026-10-19 12:51:17 +08:00

303 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 条:分类下拉的选项来自**可配置**的分类清单。
🔴 v62:清单改成"名称 + 图标"(`kindsFull`)。下拉里的 `<option>` 放不进 SVG,
所以这里用同一图标的 emoji 形态打头(与日历里一致)—— 用户一眼能对上
"下拉里选的那个"和"列表/日历上显示的那个"是同一个。 */
$kindsFull = (array)($kindsFull ?? \App\Anniversary::kindsFull((int)\App\Auth::familyId()));
$kinds = array_column($kindsFull, 'name');
$curKind = (string)($e0['kind'] ?? '');
if ($curKind !== '' && !in_array($curKind, $kinds, true)) {
// 老数据里的分类名可能已经从清单里删掉了 —— 仍然列出来,否则一保存就被清空
array_unshift($kindsFull, [
'name' => $curKind,
'icon' => \App\Anniversary::defaultIcon($curKind),
]);
$kinds = array_column($kindsFull, 'name');
}
/* 🔴 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 ($kindsFull as $kf): ?>
<option value="<?= e((string)$kf['name']) ?>" <?= $curKind === (string)$kf['name'] ? 'selected' : '' ?>><?= e(\App\AnnivIcon::emo((string)$kf['icon']) . ' ' . (string)$kf['name']) ?></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; ?>