Files
FamilyBook/templates/calendar/index.php
T
laoyang b2ca4f336e v24 第 10 批:名册真性能(根因是每卡内联表单)等九条
- #6 名册线上真实数据(1184 人)卡顿:根因是**每张卡片内联一整份分组弹层**——
  全页 2369 个 form / 27234 个 input / 22496 个 radio / 98100 DOM 节点,
  密码管理器(Bitwarden 等)逐个扫描 form 把主线程占死(用户报的就是这个冲突)。
  改为卡片只带 data 属性 + 全页**唯一一份**共享浮层 #rosterMenus。
  实测:form 2369→3、input 27234→25、DOM 98100→17659、解压 7694→2306 KB、
  长任务 1/52ms → 0。新增哨兵 _fb_formbloat_check.py(含反向验证)。
- #5 日视图翻页失效:day_columns() 无条件吸到周一,天视图只有 1 列 → 永远显示那周周一。
  改为仅 span>1 时吸附(周视图行为不变)。
- #2 财务「欠款」类别(负债);并修一个真 bug:负债填负数会让净资产**反向变大**。
  写入口 normalizeAmount 归一化 + summary/timeline 侧 abs 防御 + Schema v17 纠正老数据。
- #3 账目行「盘点」「修改」两个并列按钮(各自就地弹面板);图移到账目上方,
  总金额图跨整行 + 每个账目一条线 + 类别筛选条;charts() 改为覆盖全部账目。
- #4 日程新增「农历」「节假日」两本不可删除只读日历(app/Holiday.php):
  两级自动更新 —— 内置规则(公历固定 + 清明节气 + 农历节日)保证断网也有;
  官方 JSON 镜像自动抓取补齐调休。抓取**不在渲染路径联网**(响应后 fastcgi_finish_request
  + 频率闸门),缓存落 storage/cache。仅派生本不可写日程。
- #6 日程页设置按钮前加「跳到某一天」(选中即跳)。
- #7 起止范围改用一个 Semi dateTimeRange 控件(观影/旅程/节点/日程共 4 处);
  新增 window.fbSetDT / fbUpgradeRange;修范围重挂(React 非受控组件须 unmount 再 createRoot)
  与范围里 required 未摘除导致表单无法提交两个真实问题。

验证:e2e 786/0、batch10 44/0、batch9 38/0、batch_e 91/0、upgrade 126/0、picker 28/0、
semi_integrate 27/0、formbloat 5/0(+反向 3 红)、mobile 688/0、interact 97/0、drag 5/0、geom 5/5;
线上自检 19/19 + 线上真实账号巡检 28/0(含 Firefox 实测)。
2026-10-04 10:46:50 +08:00

355 lines
18 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
/**
* 日程 · 月 / 周 / 天 三视图(v12,v24 第 4、5 条重排)。
*
* ⚠️ v24 用户 2026-10-03 第 4 条:**左侧那一栏「我的日历本」撤掉了**,日历占满整宽。
* 原话:「日程模块,左侧的日历本显示与否,去掉,让日历充满整个宽度。
* 在右侧,也加一个设置按钮,设置中,可以设置哪些日历本显示,
* 调整日历的颜色,导入日历等功能,都放在这个设置下。」
* 所以那些功能全搬进了页头最右边那个 ☰ 里(见 _settings_menu.php)。
* 显隐改由**服务端**处理(Calendar::hiddenCalendars):藏掉的本子不参与取数,
* "+N 条"之类按可见条目算 —— 前端 display:none 会让这些数字对不上。
*
* ⚠️ 用户第 5 条:在日历里点一下就建日程(开始时间 = 点的那一格)。
* 月视图的格子按天、周/日视图按半小时——
* 后者用鼠标在列里的纵向位置反算时刻(见页面底部那两段 JS)。
*
* @param string $ym 'Y-m'
* @param array $weeks 月网格(build_month_grid 产出)
* @param array $cals 我可见的日历本(含 perm);$visCals 是其中显示着的 id
* @param string $newAt 点进来时预填的开始时间('Y-m-d H:i' 或 'Y-m-d')
*/
$canWrite = \App\Auth::canWrite();
$view = (string)($view ?? 'month');
$anchor = (string)($anchor ?? date('Y-m-d'));
$ts = strtotime($ym . '-01') ?: time();
$prevYm = date('Y-m', strtotime('-1 month', $ts));
$nextYm = date('Y-m', strtotime('+1 month', $ts));
$thisYm = date('Y-m');
$writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::canWrite($c, (int)\App\Auth::personId())));
$visCals = array_map('intval', (array)($visCals ?? []));
$hiddenCals = array_map('intval', (array)($hiddenCals ?? []));
$newAt = (string)($newAt ?? '');
// 能不能"点一下建日程":只读账号点了没反应,所以连十字光标都不给(见 CSS 的 rt-writable)
$canNew = $canWrite && (bool)$writable;
// 三种看法各自的「上一段 / 下一段」:月按整月走,周按 7 天走,天按 1 天走
$step = $view === 'week' ? 7 : ($view === 'day' ? 1 : 0);
$prevD = date('Y-m-d', strtotime('-' . $step . ' days', strtotime($anchor)));
$nextD = date('Y-m-d', strtotime('+' . $step . ' days', strtotime($anchor)));
$todayD = date('Y-m-d');
$headText = $view === 'month' ? date('Y 年 n 月', $ts)
: ($view === 'week' ? date('Y 年 n 月 j 日', strtotime($anchor)) . ' 起这一周'
: date('Y 年 n 月 j 日', strtotime($anchor)));
?>
<div class="page-head">
<h1>日程</h1>
<span class="sub"><?= e($headText) ?></span>
<div class="actions">
<?php /* v24 第 4 条:月 / 周 / 天三选一 */ ?>
<span class="seg" role="group" aria-label="看法">
<a class="seg-btn<?= $view === 'month' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'month', 'ym' => $ym])) ?>">月</a>
<a class="seg-btn<?= $view === 'week' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'week', 'd' => $anchor])) ?>">周</a>
<a class="seg-btn<?= $view === 'day' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'day', 'd' => $anchor])) ?>">天</a>
</span>
<?php if ($view === 'month'): ?>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $prevYm])) ?>">上一月</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $thisYm])) ?>">今天</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $nextYm])) ?>">下一月</a>
<?php else: ?>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $prevD])) ?>">
<?= $view === 'week' ? '上一周' : '前一天' ?>
</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $todayD])) ?>">今天</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $nextD])) ?>">
<?= $view === 'week' ? '下一周' : '后一天' ?>
</a>
<?php endif; ?>
<?php if ($canWrite && $writable): ?>
<button class="btn" type="button" id="calAddBtn">新建日程</button>
<?php endif; ?>
<?php /* 用户 2026-10-04 第 6 条原话:「日程页面,在设置按钮的前面,加一个日历按钮,
选择日历后,跳转到那一天。」
→ 一个带日历图标的按钮,点开是日期选择;选中即跳到那天的**日视图**。
⚠️ 用原生 <input type="date"> 而不是再引一个弹层:它的原生日历足够用,
而且**选中就有值**(change 事件)→ 直接跳转,不用再点一次"确定"。
桌面/手机/无 JS 三种情况下的行为都是可预期的。 */ ?>
<form class="cal-jump" method="get" action="index.php">
<input type="hidden" name="r" value="calendar">
<input type="hidden" name="view" value="day">
<label class="btn btn-ghost cal-jump-btn" title="跳到某一天">
<svg viewBox="0 0 16 16" width="17" height="17" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<rect x="2" y="3.2" width="12" height="10.8" rx="1.6"/>
<path d="M2 6.6h12M5.2 1.8v2.6M10.8 1.8v2.6"/>
</svg>
<span class="cal-jump-txt">跳到</span>
<input class="cal-jump-date" type="date" name="d" value="<?= e($anchor) ?>"
aria-label="选择要跳转的日期">
</label>
<noscript><button class="btn btn-ghost btn-sm" type="submit">跳转</button></noscript>
</form>
<?php /* 用户第 4、5 条:日历本 / 颜色 / 导入 / 默认视图 全收进这个 ☰ ——
它顶替了原来左边那一栏,所以排在**最右**(靠 .page-head .actions 的顺序)。 */ ?>
<?php $setCurrent = 'view'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<div class="cal-wrap cal-wrap-full">
<?php /* ---- 右:月网格 / 时间网格 ----
用户第 4 条:左边那一栏「我的日历本」已经撤掉(功能搬进右上角 ☰),
所以这里直接就是主区域,`.cal-wrap-full` 把网格调成"只有一列"。 */ ?>
<div class="cal-main">
<?php /* 用户第 5 条:这张表单既服务「新建日程」按钮,也服务「点日历空白处」——
后者由 JS 现填开始时间,所以表单本身不再写死 09:00。 */ ?>
<?php if ($canWrite && $writable): ?>
<div class="card card-pad cal-add" id="calAddCard" hidden>
<h2 class="cinema-h2">新建日程</h2>
<form method="post" action="<?= e(url('calendar/event_add')) ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<div class="form-grid">
<div class="field">
<label for="ce-cal">放在哪本</label>
<select class="select" id="ce-cal" name="calendar_id">
<?php foreach ($writable as $c): ?>
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
新建时「全天」默认**不勾**(用户原话)。开始时间默认:
带了 new_at 就用它(点日历的那一格),否则今天 09:00。 */ ?>
<?php
// 点的位置优先;没点就"月视图当月 → 该月 1 号 / 本月 → 今天"的 09:00
$defDay = $newAt !== '' ? substr($newAt, 0, 10)
: (($ym === $thisYm ? date('Y-m-d') : $ym . '-01'));
$defHM = $newAt !== '' && strlen($newAt) > 10
? substr($newAt, 11, 5) : '09:00';
$defEndH = sprintf('%02d', (min(23, (int)substr($defHM, 0, 2) + 1)));
$evFields = ['starts_at' => $defDay . ' ' . $defHM . ':00',
'ends_at' => $defDay . ' ' . $defEndH . ':' . substr($defHM, 3, 2) . ':00'];
$evAllDay = false;
require __DIR__ . '/_event_fields.php';
?>
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit">加上</button>
<button class="btn btn-ghost btn-sm" type="button" id="calAddCancel">取消</button>
</div>
</form>
</div>
<?php endif; ?>
<?php if ($view === 'week' || $view === 'day'): ?>
<?php /* ===== 周 / 天:按小时铺的时间网格(全天日程单独挂顶上) ===== */ ?>
<div class="card cal-tg-card">
<div class="tg-head">
<div class="tg-gutter"></div>
<?php foreach (($cols ?? []) as $c): ?>
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>">
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
<span class="tg-dd"><?= (int)$c['day'] ?></span>
</div>
<?php endforeach; ?>
</div>
<?php $tgAll = $tgrid['all_day'] ?? []; ?>
<?php if (array_filter($tgAll)): ?>
<div class="tg-allday">
<div class="tg-gutter"><span class="tg-tag">全天</span></div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-adcell">
<?php foreach (($tgAll[$i] ?? []) as $ev): ?>
<a class="cal-ev is-allday" data-cal="<?= (int)$ev['cal'] ?>"
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>"><?= e((string)$ev['title']) ?></a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="tg-body">
<div class="tg-gutter tg-hours">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-hr"><?= sprintf('%02d', $h) ?></span>
<?php endfor; ?>
</div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-col<?= $c['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$c['date']) ?>">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-line"></span>
<?php endfor; ?>
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
style="background: <?= e((string)$ev['color']) ?>; top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>">
<b><?= e((string)$ev['time']) ?></b> <?= e((string)$ev['title']) ?>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<div class="card cal-grid-card">
<div class="cal-head">
<?php foreach (['日', '一', '二', '三', '四', '五', '六'] as $wd): ?>
<div class="cal-wd"><?= e($wd) ?></div>
<?php endforeach; ?>
</div>
<div class="cal-grid">
<?php foreach ($weeks as $wk): ?>
<?php foreach ($wk as $cell): ?>
<?php /* 用户第 5 条:点格子里的空白 = 在这天建日程。
用 data-new-at 把日期带过去,由底部 JS 交给新建表单
(点了小条本身是"打开那条日程",由 <a> 自己处理,JS 会排除掉)。 */ ?>
<div class="cal-cell<?= $cell['in_month'] ? '' : ' is-out' ?><?= $cell['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$cell['date']) ?>">
<div class="cal-dnum"><?= (int)$cell['day'] ?></div>
<div class="cal-evs">
<?php $n = 0; foreach ($cell['events'] as $ev): $n++; ?>
<?php if ($n <= 3): ?>
<a class="cal-ev<?= empty($ev['all_day']) ? '' : ' is-allday' ?>"
data-cal="<?= (int)$ev['cal'] ?>"
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>">
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b> <?php endif; ?>
<?= e((string)$ev['title']) ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
<?php if (count($cell['events']) > 3): ?>
<span class="cal-more">+<?= count($cell['events']) - 3 ?> 条</span>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<script>
(function () {
/* ---- 新建日程:按钮开合 + 「点日历空白处」预填开始时间 ----
⚠️ 上一版的「日历本显隐」JS 已经**整段删掉**:显隐改成服务端处理
(Calendar::hiddenCalendars,见路由里的说明),前端不再需要它。
留着会让人以为"改一下勾选就能切换",但那些勾选框已经不在这儿了。 */
var btn = document.getElementById('calAddBtn');
var card = document.getElementById('calAddCard');
var cancel = document.getElementById('calAddCancel');
if (btn && card) {
btn.addEventListener('click', function () {
card.hidden = !card.hidden;
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
});
}
if (cancel && card) { cancel.addEventListener('click', function () { card.hidden = true; }); }
/* ---- 用户第 5 条:在日历里点一下 → 弹新建,开始时间 = 点的位置 ----
月视图:一格 = 一天 → 开始时间用那天 09:00("当日默认"比 00:00 更像个日程)。
周/日视图:按鼠标在**那一列里的纵向位置**反算时刻,最接近的半小时 ——
点在"下午三点那一段"就该得到 15:00,这是"点哪儿建哪儿"的全部意义。 */
var startField = document.querySelector('.field.ev-dt[data-dt]');
var startDate = startField ? startField.querySelector('input.dt-date') : null;
var startHour = startField ? startField.querySelector('select.dt-hour') : null;
var startMin = startField ? startField.querySelector('select.dt-minute') : null;
var startSec = startField ? startField.querySelector('select.dt-second') : null;
function setStart(dateStr, hh, mm) {
// ⚠️ 优先走全局接口:它会把原生字段与 Semi 控件**一起**更新。
// 直接写 startDate.value 的话,Semi 是非受控组件、显示不会跟着变 ——
// 用户会看到"我点了 15:00,控件里还写着 09:00"(提交的却是 15:00)。
if (typeof window.fbSetDT === 'function'
&& window.fbSetDT('starts_at', dateStr + ' ' + (hh || '09') + ':' + (mm || '00') + ':00')) {
return;
}
// 兜底:Semi 还没加载完(或加载失败)→ 原生控件照旧能用
if (!startDate) return;
startDate.value = dateStr;
if (hh !== null && startHour) { startHour.value = hh; }
if (mm !== null && startMin) { startMin.value = mm; }
if (startSec) { startSec.value = '00'; }
}
function openNew(dateStr, hh, mm) {
if (!card) return;
card.hidden = false;
setStart(dateStr, hh, mm);
card.scrollIntoView({ block: 'nearest' });
// 标题是第一个输入框,直接把光标放过去 —— 点完就能打字
var t = document.getElementById('ev-title');
if (t) { t.focus(); }
}
/* 月视图格子 */
Array.prototype.forEach.call(document.querySelectorAll('.cal-cell[data-new-at]'), function (cell) {
cell.addEventListener('click', function (ev) {
// 点在小条上 = 想打开那条日程(<a> 的默认行为),不要抢
if (ev.target.closest && ev.target.closest('.cal-ev')) { return; }
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
openNew(cell.getAttribute('data-new-at'), '09', '00');
});
});
/* 周/日视图的列:纵向位置 → 时刻。
列高 1056px = 24 小时(见 .tg-col 的 CSS),所以 1 分钟 = 1056/1440 px。 */
Array.prototype.forEach.call(document.querySelectorAll('.tg-col[data-new-at]'), function (col) {
col.addEventListener('click', function (ev) {
if (ev.target.closest && ev.target.closest('.tg-block')) { return; }
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
var box = col.getBoundingClientRect();
var ratio = (ev.clientY - box.top) / Math.max(1, box.height);
var mins = Math.max(0, Math.min(24 * 60 - 30, Math.round(ratio * 1440 / 30) * 30));
var hh = String(Math.floor(mins / 60)).padStart(2, '0');
var mm = String(mins % 60).padStart(2, '0');
openNew(col.getAttribute('data-new-at'), hh, mm);
});
});
/* 从 ?new_at= 进来(比如别处链过来)→ 直接开弹窗并预填 */
var preset = <?= json_encode($newAt, JSON_UNESCAPED_UNICODE) ?>;
if (preset) {
var parts = String(preset).split(/[ T]/);
if (parts[0]) {
var hm = (parts[1] || '').split(':');
openNew(parts[0], hm[0] || '09', hm[1] || '00');
}
}
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
})();
/* ---- 用户第 6 条:「跳到某一天」—— 选中即跳,不用再点确定 ---- */
(function () {
var box = document.querySelector('.cal-jump');
if (!box) { return; }
var inp = box.querySelector('.cal-jump-date');
if (!inp) { return; }
inp.addEventListener('change', function () {
if (inp.value) { box.submit(); }
});
// 点标签区域也应能弹出原生日历(有些浏览器只认输入框本身)
var lab = box.querySelector('.cal-jump-btn');
if (lab) {
lab.addEventListener('click', function (e) {
if (e.target === inp) { return; }
if (typeof inp.showPicker === 'function') {
try { inp.showPicker(); } catch (err) { /* 用户手势之外会被拒,忽略 */ }
}
inp.focus();
});
}
})();
</script>