Files
FamilyBook/templates/calendar/_event_fields.php
T
laoyang 02f5d072a4 家谱卡片重做 / 首页控制台 / 名册改列表 / 重复日程与旅程公开
用户 2026-10-03 提的四条:

① 家谱卡片重做
   · 去掉「⇄ 姻亲」按钮,改成**每个人**卡片右下角都有「以 TA 为中心」
     (点了以他重画一张家谱,涵盖原来的姻亲场景)。
   · 头像外框改**长方形**(96×72,宽度贴合头像),object-fit: contain 不裁切,
     姓名/称呼在头像下方。
   · 修「点右下角按钮误进成员页」:卡片不再整张是 <a>,
     只有姓名是链接,右下角是独立的 <button>(物理上分开,不抢点击)。

② 家谱文案与按钮清理
   · 去掉副标题与下方图例说明;整块去掉「看全部 / 回到我 / 回到我的家谱」
     (顺带消掉"出现两个回到我的家谱")。镜头由库自动 fit,拖拽/滚轮即可。

③ 首页控制台
   · 标题下显示个人 slogan;去掉右侧三个统计;「我的资料/我的家谱」换成图标按钮;
     去掉与导航条重复的「名册」按钮。
   · 去掉「接下来的日子」(生日归到"未来七天"里,不再重复)。
   · 新增统计信息块:按时段随机问候 + 今日天气(Open-Meteo,免 key,可留空不显示)
     + 近七天日程/待办/逾期数。
   · 新增各模块小结卡(任务/日程/纪念日/观影/旅程/财务/名册/家谱/资源)。
   · 个人设置新增「首页上的模块」:可勾选与拖动排序(每人一份,存 user_settings)。
   · 保留「往年的今天」。

④ 名册改列表
   · 卡片网格 → **竖向列表**:左头像、中间姓名+与我的关系+手机号+生日。
   · 修拖动「只能往后不能往前」:落点改为显式记录"插到谁前面/后面",
     并排除被拖元素自身。
   · 去掉那句拖动提示(能力保留)。
   · 搜索新增**姓名首字母**检索(y / yy / yyq 都命中「杨永全」):
     新 app/Pinyin.php,intl 优先 + 内置常用字表兜底(本地无 intl 也能测);
     纯字母/数字查询词不走 SQL LIKE(拼音与加密手机号在库里都比不了)。

附带上两轮的收尾:
   · 菜单点外部自动关闭(全局事件委托,覆盖所有 details.menu)。
   · 旅程 is_public:列表与日历只显示公开的;详情页不拦(不丢数据)。
   · 重复日程:新 app/Repeat.php(RRULE 解析/生成/展开,含"工作日"),
     新表 calendar_event_skips(RFC 的 EXDATE);日历按规则展开;
     编辑支持三档「全部 / 这一次及以后 / 仅此一次」。
   · 结束时间可添加/去掉,并随开始时间联动。

验证:端到端 772 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 26、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 11:30:48 +08:00

264 lines
12 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
/**
* 日程表单的字段块(新建与编辑共用一份 —— 复制一遍迟早改漏一处)。
*
* 字段按 **RFC 5545(iCalendar)/ RFC 4791(CalDAV)** 的 VEVENT 属性定:
* 标题 = SUMMARY
* 地点 = LOCATION
* 开始/结束 = DTSTART / DTEND(DATETIME,精确到秒)
* 全天 —— VEVENT 没有"全天"这个属性,约定俗成是把 DTSTART 写成 DATE 值;
* 我们统一存 DATETIME,全天时时间部分为 00:00:00,导出 ics 时再落成 DATE 值。
* 提醒 = VALARM 的 TRIGGER(提前多久);不提醒 = 不写 VALARM
* 状态 = STATUS(CONFIRMED / TENTATIVE / CANCELLED)
* 忙闲 = TRANSP(OPAQUE=忙 / TRANSPARENT=空闲)
* 备注 = DESCRIPTION
* 附件 = ATTACH
*
* 用法(include 前设好):
* $evFields 正在编辑的日程(新建时传空数组)
* $evAllDay 全天的默认值(新建默认**不勾**,用户 2026-10-02 第 6 条)
*/
$evF = is_array($evFields ?? null) ? $evFields : [];
$evAllDayDefault = (bool)($evAllDay ?? false);
$evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evAllDayDefault;
?>
<div class="field full">
<label for="ev-title">标题 *</label>
<input class="input" id="ev-title" name="title" required
value="<?= e((string)($evF['title'] ?? '')) ?>" placeholder="如:家长会">
</div>
<div class="field">
<label for="ev-location">地点</label>
<input class="input" id="ev-location" name="location"
value="<?= e((string)($evF['location'] ?? '')) ?>" placeholder="如:学校三楼会议室">
</div>
<div class="field">
<label>提醒</label>
<?php /* 不提醒 = NULL(导出 ics 时不写 VALARM);档位就是用户第 6 条列的那些。 */ ?>
<select class="select" name="reminder_minutes" aria-label="提前多久提醒">
<?php $cur = $evF['reminder_minutes'] ?? null; ?>
<option value=""<?= $cur === null || $cur === '' ? ' selected' : '' ?>>不提醒</option>
<?php foreach (\App\Calendar::REMINDERS as $mins => $word): ?>
<option value="<?= (int)$mins ?>"<?= (string)$cur === (string)$mins ? ' selected' : '' ?>>
<?= e($word) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php /* v24 第 5、6 条:起止各一个「日期 + 时分秒」合在一起的控件(全局统一),
不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?>
<div class="field ev-dt" data-dt>
<label>开始 *</label>
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?>
</div>
<?php /* 用户 2026-10-03 第 6 条:「添加结束日期与时间」——
结束是可选的:没填就表示"就一个时刻/就这一天",填了才画成一段。
所以给一个显式开关,而不是让人对着空控件猜"不填行不行"。 */ ?>
<?php $hasEnd = trim((string)($evF['ends_at'] ?? '')) !== ''; ?>
<div class="field ev-dt" data-dt id="ev-end-field"<?= $hasEnd ? '' : ' hidden' ?>>
<label>结束</label>
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?>
</div>
<div class="field" id="ev-end-add">
<label>结束时间</label>
<button type="button" class="btn btn-ghost btn-sm" id="ev-end-add-btn">+ 添加结束时间</button>
<span class="field-hint">不填就表示没有明确的结束(只占开始那一刻/那一天)</span>
</div>
<div class="field">
<label>
<input type="checkbox" id="ev-allday" name="all_day" value="1"
<?= $evIsAllDay ? 'checked' : '' ?>>
全天
</label>
<span class="field-hint">勾上就不管具体几点,只占这一天</span>
</div>
<?php /* 用户 2026-10-03 第 5、6 条:重复日程。
放在「全天」之后的自然位置:先定"哪一天/几点",再定"要不要重复"。
周期用 FREQ 记号,结束条件三档(一直 / N 次 / 到某天),另加"工作日"。 */ ?>
<div class="field">
<label for="ev-freq">重复</label>
<select class="select" id="ev-freq" name="freq">
<?php $rp = \App\Repeat::parse((string)($evF['rrule'] ?? '')); ?>
<?php foreach (\App\Repeat::FREQS as $k => $w): ?>
<option value="<?= e($k) ?>"<?= (string)$rp['freq'] === (string)$k ? ' selected' : '' ?>><?= e($w) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field" id="ev-rep-end-box" hidden>
<label for="ev-rep-end">结束条件</label>
<select class="select" id="ev-rep-end" name="rep_end">
<?php foreach (\App\Repeat::ENDS as $k => $w): ?>
<option value="<?= e($k) ?>"<?= $rp['count'] > 0 ? '' : '' ?>><?= e($w) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field" id="ev-rep-count-box" hidden>
<label for="ev-rep-count">重复次数(含第一次)</label>
<input class="input" type="number" min="1" max="2000" id="ev-rep-count" name="rep_count"
value="<?= (int)($rp['count'] ?: 10) ?>">
</div>
<div class="field" id="ev-rep-until-box" hidden>
<label for="ev-rep-until">重复到</label>
<input class="input" type="date" id="ev-rep-until" name="rep_until"
value="<?= e((string)($rp['until'] ?? '')) ?>">
</div>
<div class="field">
<label for="ev-status">状态</label>
<select class="select" id="ev-status" name="status">
<?php $st = (string)($evF['status'] ?? 'confirmed'); ?>
<option value="confirmed"<?= $st === 'confirmed' ? ' selected' : '' ?>>确定</option>
<option value="tentative"<?= $st === 'tentative' ? ' selected' : '' ?>>待定</option>
<option value="cancelled"<?= $st === 'cancelled' ? ' selected' : '' ?>>取消</option>
</select>
</div>
<div class="field">
<label for="ev-transp">这段时间算</label>
<select class="select" id="ev-transp" name="transp">
<?php $tp = (int)($evF['transp'] ?? 0); ?>
<option value="0"<?= $tp === 0 ? ' selected' : '' ?>>忙碌</option>
<option value="1"<?= $tp === 1 ? ' selected' : '' ?>>空闲(别人还能约)</option>
</select>
</div>
<div class="field full">
<label for="ev-note">备注</label>
<textarea class="input" id="ev-note" name="note" rows="3"
placeholder="要带什么、跟谁一起…"><?= e((string)($evF['note'] ?? '')) ?></textarea>
</div>
<div class="field full">
<label for="ev-attach">附件(可多选)</label>
<input class="input" type="file" id="ev-attach" name="attach[]" multiple>
</div>
<script>
(function () {
/* 「全天」勾上就把两个时刻控件的**时间部分**收起来 —— 日期还得留着。
只藏 .dt-time(时/分/秒那一段),.dt-date 是日期,藏了就没法选哪天了。 */
var box = document.getElementById('ev-allday');
if (!box) return;
function sync() {
Array.prototype.forEach.call(document.querySelectorAll('[data-dt] .dt-time'), function (el) {
el.hidden = box.checked;
});
}
box.addEventListener('change', sync);
sync();
/* ---- 用户第 6 条:结束时间 ----
① 「+ 添加结束时间」把结束控件放出来;
② 结束时间**默认跟着开始时间走**:改了开始,若结束还没填(或原本等于旧开始),
就把结束同步过去 —— 常见用法是"改个开始,结束忘了改",结果时长莫名其妙。
但只要用户**自己动过结束**,就不再自动同步(别抢用户的输入)。 */
var startBox = document.querySelector('[data-dt] input.dt-date');
var endField = document.getElementById('ev-end-field');
var endAdd = document.getElementById('ev-end-add');
var endAddBtn = document.getElementById('ev-end-add-btn');
var startField = document.querySelector('.field.ev-dt');
function endInputs() {
if (!endField) return [];
return Array.prototype.slice.call(endField.querySelectorAll('input, select'));
}
function showEnd(on) {
if (endField) { endField.hidden = !on; }
if (endAdd) { endAdd.hidden = on; }
}
var endTouched = false;
endInputs().forEach(function (el) {
el.addEventListener('change', function () { endTouched = true; });
});
if (endAddBtn) {
endAddBtn.addEventListener('click', function () { showEnd(true); endTouched = false; });
}
// 移除结束(改回"没有结束")
if (endField && !endField.querySelector('.ev-end-del')) {
var del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-ghost btn-sm ev-end-del';
del.textContent = '去掉结束时间';
del.addEventListener('click', function () {
endInputs().forEach(function (el) {
if (el.tagName === 'SELECT') { el.value = '00'; }
else { el.value = ''; }
});
showEnd(false);
endTouched = false;
});
endField.appendChild(del);
}
// 开始变了 → 结束(若没被手动改过)跟着走同一时刻
if (startField) {
startField.addEventListener('change', function () {
if (endTouched || !endField || endField.hidden) return;
var sd = startField.querySelector('input.dt-date');
var sh = startField.querySelector('select.dt-hour');
var sm = startField.querySelector('select.dt-minute');
var ss = startField.querySelector('select.dt-second');
var ed = endField.querySelector('input.dt-date');
var eh = endField.querySelector('select.dt-hour');
var em = endField.querySelector('select.dt-minute');
var es = endField.querySelector('select.dt-second');
if (sd && ed) { ed.value = sd.value; }
if (sh && eh) { eh.value = sh.value; }
if (sm && em) { em.value = sm.value; }
if (ss && es) { es.value = ss.value; }
});
}
// 初值由服务端决定(有 ends_at 就是展开的),JS 只负责"之后"的显隐
showEnd(<?= $hasEnd ? 'true' : 'false' ?>);
/* 重复:选了周期才显示"结束条件";结束条件选"次数"才显示次数框、选"到某天"才显示日期框。
三个框默认都 hidden —— 不重复时一个都不该出现(少即是清楚)。 */
var freq = document.getElementById('ev-freq');
var endBox = document.getElementById('ev-rep-end-box');
var endSel = document.getElementById('ev-rep-end');
var cntBox = document.getElementById('ev-rep-count-box');
var untilBox = document.getElementById('ev-rep-until-box');
function syncRep() {
if (!freq || !endBox) return;
var repeating = freq.value !== '';
endBox.hidden = !repeating;
var how = endSel ? endSel.value : 'never';
if (cntBox) { cntBox.hidden = !repeating || how !== 'count'; }
if (untilBox) { untilBox.hidden = !repeating || how !== 'until'; }
}
if (freq) { freq.addEventListener('change', syncRep); }
if (endSel) { endSel.addEventListener('change', syncRep); }
// 回填:编辑一条已有的重复日程时,把结束条件按库里的值选对
<?php if ($rp['count'] > 0): ?>
if (endSel) { endSel.value = 'count'; }
<?php elseif ($rp['until'] !== ''): ?>
if (endSel) { endSel.value = 'until'; }
<?php endif; ?>
syncRep();
/* 「全天」+「重复」的组合提示:重复日程必须有开始日期,
否则展开时找不到锚点(后端会当成"不重复"处理)。 */
var startDate = document.querySelector('[data-dt] input.dt-date');
if (freq && startDate) {
freq.addEventListener('change', function () {
if (freq.value !== '' && startDate.value === '') {
startDate.setCustomValidity('重复日程要先选开始日期');
} else {
startDate.setCustomValidity('');
}
});
}
})();
</script>
<?php unset($evF, $evAllDayDefault, $evIsAllDay, $evFields, $evAllDay); ?>