日程新建/修改不再刷新页面:服务端片段 + 就地重绘 (#37)
用户原话(2026-11-01):
「修改和新建,都不应该刷新页面,而是使用js动态调整。」
## 改法:不再手写复刻渲染,改成"服务端渲染片段 → 前端整块替换"
历史上「新建不刷新」是靠手写复刻服务端渲染做的(`putMonth` / `putTimed` /
`relayoutColumn`),它自己的注释里就记着一次真实事故:新加一条 07:00 的日程被
append 到末尾 ⇒ 落进「+N 条」看不见,刷新后又跑到第一位。修改要处理的情况更多
(跨天 / 全天状态 / 换日历本改颜色 / 改重复规则 / 挪到别的月),复刻必然出错。
⇒ 现在只有**一处渲染**:前端取服务端画好的视图片段整块替换。
"插进去的样子"与"刷新一次的样子"必然相同。
## 服务端
- `calendar/event_update` 认 `X-Requested-With`(与 event_add / event_del 同一套约定)
回 JSON;失败也回 JSON(可预期的拒绝不该把用户甩到一个刷新过的页面上)。
三处失败出口 + 四处成功出口收口成两个闭包,避免"某条路径仍刷页"。
- 抽出 `calendar_view_data()`:整页渲染与局部重绘共用同一份取数/建网格逻辑。
- 新增 `calendar/view` 片段路由(月/周/日视图片段)。
- 抽出 `home_agenda_events()` + 新增 `home/agenda` 片段路由(首页卡片)。
- 抽出 `templates/calendar/_view.php`:视图区从页面模板里搬出来(原文一字未改,
已用脚本逐行比对证明 270 行零差异)。
- `templates/layout.php` 加载 `cal_sync.js`。
## 前端
- 新增 `assets/js/cal_sync.js`:`refresh()` 按当前视图取片段替换;
只换内容不换容器;保首页卡片滚动位置;重绘后补跑红线定时器;
失败退回整页刷新(宁可刷一次,也不要"没刷新却也没变")。
- `entry_popup.js` 的「保存」改 fetch 提交 → 关浮层 + 轻提示 + 局部重绘。
名册/观影/行程/任务四个来源保持原生提交,范围不收不必要的回归面。
- 删掉日历页那 ~270 行手写插入,新建统一走同一个 `refresh()`。
- 「点格子新建」的 per-element 绑定改成 document 委托(重绘后新节点才有监听)。
## 本轮实测抓到并修掉的真 bug
`form.id` 被同名 input 遮蔽:日程编辑表里有 `<input name="id">`,
HTMLFormElement 的具名访问优先级高于原型上的 `id` ⇒ `form.id === 'calEditForm'`
恒为 false ⇒ 日程也走原生提交、整页刷新。
改用 `form.getAttribute('id')`。
## 验证(全部做过反向验证)
- 浏览器端到端 28 项:编辑/新建都不导航、改动原地可见、视图不跳、数据岛同步、
重绘后点格子仍有效、红线定时器重建、无 JS 异常
- 反向验证 7 项:把 refresh 置空 / 改成 reload / 不调 reinit / 抹掉上下文属性,
判据逐条转红(证明判据有牙齿)
- DOM 等价性 4 项:月/周/日视图与首页卡片,重绘后与刷新后的 DOM **逐字节相同**
(这条判据本身也做了反向验证:删一个字、多一个节点都能抓到)
- 服务端 11 项:非 AJAX 仍 302 且真存库、AJAX 回 JSON、失败也回 JSON、
片段路由可用、未登录被拒
- #35 跨天回归 11 项:三段的 seg/cont/is-seg-* 标记全在、贴满整列、不进全天行
- 静态检查:37 个模板片段无作用域违规;20 块内联 JS 语法全过;运行期零 Warning
This commit is contained in:
@@ -845,6 +845,154 @@ function build_time_grid(array $events, array $cols): array
|
||||
return ['all_day' => $allDay, 'timed' => $timedOut];
|
||||
}
|
||||
|
||||
/**
|
||||
* 日历视图的全部数据(工单 #37 抽出)。
|
||||
*
|
||||
* 整页渲染(`calendar` 路由)与局部重绘(`calendar/view` 路由)共用这一个函数:
|
||||
* 只有一处取数、一处建网格,"片段与整页不一致"这类 bug 就无从产生。
|
||||
*
|
||||
* @return array{view:string,ym:string,anchor:string,nowTop:float,newAt:string,
|
||||
* cals:array,visCals:array<int,int>,hiddenCals:array<int,int>,
|
||||
* cols:array,weeks:array,tgrid:?array,canNew:bool}
|
||||
*/
|
||||
function calendar_view_data(int $familyId, int $me): array
|
||||
{
|
||||
// v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落**默认视图**。
|
||||
// ⚠️ 默认视图是**每人一份**的设置(用户第 5 条:设置里选月/周/天)。
|
||||
// 但只在**没带 view 参数**时才生效 —— 手点了「周」按钮之后,
|
||||
// 那个选择必须优先,否则每次翻页都被默认值拽回去。
|
||||
$viewIn = trim((string)($_GET['view'] ?? ''));
|
||||
if ($viewIn === '') {
|
||||
$view = \App\Calendar::defaultView($me);
|
||||
} else {
|
||||
$view = in_array($viewIn, ['month', 'week', 'day'], true) ? $viewIn : 'month';
|
||||
}
|
||||
$anchor = trim((string)($_GET['d'] ?? ''));
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $anchor)) {
|
||||
$anchor = date('Y-m-d');
|
||||
}
|
||||
$ym = substr($anchor, 0, 7);
|
||||
|
||||
/* 🔴 v24 第 21 批 第 7 条:**打开日历顺手补一本「我的日程」**。
|
||||
为什么必须在这儿补(而不是只改 DEFAULT_KINDS):
|
||||
`Calendar::seedDefaults()` 只在**安装**与**结构升级**两条路径上跑过,
|
||||
线上这套库早就升完级了 → 老用户永远拿不到那一本。
|
||||
结果:可写的普通本 0 本、"放在哪本"全是派生本、默认还选中排最前的
|
||||
**观影** → 用户直接点「加上」建出来的是一条**观影记录**(建错东西)。
|
||||
⚠️ 幂等(owner_id + kind='custom' 有就不再补),一次请求只多一条 SELECT。 */
|
||||
\App\Calendar::ensurePersonalBook($familyId, $me);
|
||||
$cals = \App\Calendar::mine($familyId, $me);
|
||||
// 用户第 4 条:哪些本子显示,改由设置里的复选框决定(以前是左侧那一栏)。
|
||||
// 藏掉的本子**不参与取数**,而不是"取出来再 display:none" ——
|
||||
// 前端藏的话,关掉一本之后月视图的"+N 条"仍然按它算,数字对不上。
|
||||
$hiddenCals = \App\Calendar::hiddenCalendars($me);
|
||||
$visCals = [];
|
||||
foreach ($cals as $c) {
|
||||
if (!isset($hiddenCals[(int)$c['id']])) {
|
||||
$visCals[] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
$cols = [];
|
||||
$weeks = [];
|
||||
$tgrid = null;
|
||||
if ($view === 'month') {
|
||||
$gym = trim((string)($_GET['ym'] ?? ''));
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $gym)) {
|
||||
$ym = $gym;
|
||||
}
|
||||
[$from, $to] = month_grid_range($ym);
|
||||
$events = \App\Calendar::month($familyId, $visCals, $me, $from, $to);
|
||||
$weeks = build_month_grid($ym, $events);
|
||||
} else {
|
||||
// 周视图 7 列、天视图 1 列,共用同一个时间网格
|
||||
$cols = day_columns($anchor, $view === 'week' ? 7 : 1);
|
||||
$from = $cols[0]['date'];
|
||||
$to = $cols[count($cols) - 1]['date'];
|
||||
$events = \App\Calendar::month($familyId, $visCals, $me, $from, $to);
|
||||
$tgrid = build_time_grid($events, $cols);
|
||||
}
|
||||
|
||||
// 用户第 5 条:在日历里点一下就建日程,**开始时间 = 点的那一格**。
|
||||
// 点空白处只带日期/时刻过来,标题等还是空的(不能替用户瞎填)。
|
||||
$newAt = trim((string)($_GET['new_at'] ?? ''));
|
||||
|
||||
/* ---- 当前时间红线(v24 第 20 批 用户第 6 条)----
|
||||
用户原话:「周视图和日视图,应该在视图中间显示一条红线…来表示当前时间
|
||||
在视图上的刻度和位置。」
|
||||
⚠️ 位置在**服务端**算好(与 .tg-block 的 top/height 同一套算法:
|
||||
列高固定 24 小时 → 1 分钟 = 100/1440 %),前端只负责"过一会儿自己挪一下"。
|
||||
不在 CSS 里写死时间,也不在前端算 —— 算法只有一处,不会两边对不上。 */
|
||||
$nowMinutes = ((int)date('G')) * 60 + (int)date('i');
|
||||
$nowTop = round($nowMinutes / 1440 * 100, 4);
|
||||
|
||||
/* 能不能"点一下建日程" —— 与模板里那个 $canNew 同一条判据。
|
||||
⚠️ 必须在**同一个地方**算:片段重绘时也要知道这一列能不能点。 */
|
||||
$writable = \App\Calendar::filterWritableForEvent($cals, $me);
|
||||
$canNew = \App\Auth::canWrite() && (bool)$writable;
|
||||
|
||||
return [
|
||||
'view' => $view, 'ym' => $ym, 'anchor' => $anchor,
|
||||
'nowTop' => $nowTop, 'newAt' => $newAt,
|
||||
'cals' => $cals,
|
||||
'visCals' => array_map('intval', array_column($visCals, 'id')),
|
||||
'hiddenCals' => array_keys($hiddenCals),
|
||||
'cols' => $view === 'month' ? [] : $cols,
|
||||
'weeks' => $view === 'month' ? $weeks : [],
|
||||
'tgrid' => $view === 'month' ? null : $tgrid,
|
||||
'canNew' => $canNew,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页「今天的日程」卡片的事件清单(工单 #37 抽出)。
|
||||
*
|
||||
* @param array $soon 已经算好的"未来七天"汇总(Calendar::month 产出,已滤掉农历)
|
||||
* @param string $today 'Y-m-d'
|
||||
* @return array 只含今天、全天在前、其余按时刻升序
|
||||
*
|
||||
* ⚠️ 与「未来七天」不同:这里**只要今天**,而且要按时间排好
|
||||
* (日视图从上往下就是时间序)。
|
||||
*/
|
||||
function home_agenda_events(array $soon, string $today): array
|
||||
{
|
||||
$evs = array_values(array_filter($soon, static function (array $e) use ($today): bool {
|
||||
return (string)($e['date'] ?? '') === $today;
|
||||
}));
|
||||
usort($evs, static function (array $a, array $b): int {
|
||||
// 全天事件(无时间)排最前 —— 日视图的习惯就是这样
|
||||
$ta = (string)($a['time'] ?? '');
|
||||
$tb = (string)($b['time'] ?? '');
|
||||
if ($ta === '' && $tb !== '') { return -1; }
|
||||
if ($tb === '' && $ta !== '') { return 1; }
|
||||
return strcmp($ta, $tb);
|
||||
});
|
||||
return $evs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「今天」的日程原始清单(给卡片片段路由用)。
|
||||
*
|
||||
* ⚠️ 与 `home` 路由里那段取数的口径必须一致(含"未来七天"的范围、滤掉农历)——
|
||||
* 所以这里**照抄**同一套调用,而不是另写一个"更简单"的版本。
|
||||
* 将来 home 路由那边改了取数口径,这里必须跟着改(两处都指向 Calendar::month)。
|
||||
*
|
||||
* 为什么不把 home 路由的取数也抽成一个函数:home 一次要算概述/任务/纪念日/天气
|
||||
* 十几样东西,为了一条卡片抽出整块反而让那个路由更难读。这里只保留"卡片需要的
|
||||
* 那两行",口径靠注释钉住。
|
||||
*/
|
||||
function home_agenda_events_live(int $familyId, int $me, string $today): array
|
||||
{
|
||||
$cals = \App\Calendar::mine($familyId, $me);
|
||||
$soon = \App\Calendar::month($familyId, $cals, $me, $today, date('Y-m-d', strtotime('+7 days')));
|
||||
$soon = array_values(array_filter($soon, static function (array $e): bool {
|
||||
return (string)($e['kind'] ?? '') !== 'lunar';
|
||||
}));
|
||||
return home_agenda_events($soon, $today);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 读任务的「截止日期(+ 可选时刻)」。
|
||||
*
|
||||
@@ -1112,19 +1260,10 @@ switch ($r) {
|
||||
. Lunar::dayName((int)$lunarToday['day']));
|
||||
$todayMarks = Home::todayMarks($familyId, $me, $today);
|
||||
|
||||
// 今天的日程(v24 第 24 批 用户第 3 条:左侧那块要"今天的日程,日视图那样")
|
||||
// ⚠️ 与「未来七天」不同:这里**只要今天**,而且要按时间排好(日视图从上往下就是时间序)。
|
||||
$todayEvents = array_values(array_filter($soon, static function (array $e) use ($today): bool {
|
||||
return (string)($e['date'] ?? '') === $today;
|
||||
}));
|
||||
usort($todayEvents, static function (array $a, array $b): int {
|
||||
// 全天事件(无时间)排最前 —— 日视图的习惯就是这样
|
||||
$ta = (string)($a['time'] ?? '');
|
||||
$tb = (string)($b['time'] ?? '');
|
||||
if ($ta === '' && $tb !== '') { return -1; }
|
||||
if ($tb === '' && $ta !== '') { return 1; }
|
||||
return strcmp($ta, $tb);
|
||||
});
|
||||
/* 今天的日程(v24 第 24 批 用户第 3 条:左侧那块要"今天的日程,日视图那样")。
|
||||
🔵 工单 #37:抽成 `home_agenda_events()`,卡片局部重绘时调同一个函数 ——
|
||||
否则"新建一条后卡片上出现的"与"刷新一次后的"会是两套算法算出来的。 */
|
||||
$todayEvents = home_agenda_events($soon, $today);
|
||||
|
||||
// 天气:地点在**个人设置**里(v24 用户 2026-10-03 第 1 条),每人一份。
|
||||
// 三级回落都在这一个调用里:自定义经纬度 → 内置城市表 → 不显示。
|
||||
@@ -5317,70 +5456,23 @@ $want = array_keys($want);
|
||||
case 'calendar':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
// v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落**默认视图**。
|
||||
// ⚠️ 默认视图是**每人一份**的设置(用户第 5 条:设置里选月/周/天)。
|
||||
// 但只在**没带 view 参数**时才生效 —— 手点了「周」按钮之后,
|
||||
// 那个选择必须优先,否则每次翻页都被默认值拽回去。
|
||||
$viewIn = trim((string)($_GET['view'] ?? ''));
|
||||
if ($viewIn === '') {
|
||||
$view = Calendar::defaultView($me);
|
||||
} else {
|
||||
$view = in_array($viewIn, ['month', 'week', 'day'], true) ? $viewIn : 'month';
|
||||
}
|
||||
$anchor = trim((string)($_GET['d'] ?? ''));
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $anchor)) {
|
||||
$anchor = date('Y-m-d');
|
||||
}
|
||||
$ym = substr($anchor, 0, 7);
|
||||
/* 🔴 v24 第 21 批 第 7 条:**打开日历顺手补一本「我的日程」**。
|
||||
为什么必须在这儿补(而不是只改 DEFAULT_KINDS):
|
||||
`Calendar::seedDefaults()` 只在**安装**与**结构升级**两条路径上跑过,
|
||||
线上这套库早就升完级了 → 老用户永远拿不到那一本。
|
||||
结果:可写的普通本 0 本、"放在哪本"全是派生本、默认还选中排最前的
|
||||
**观影** → 用户直接点「加上」建出来的是一条**观影记录**(建错东西)。
|
||||
⚠️ 幂等(owner_id + kind='custom' 有就不再补),一次请求只多一条 SELECT。 */
|
||||
Calendar::ensurePersonalBook($familyId, $me);
|
||||
$cals = Calendar::mine($familyId, $me);
|
||||
// 用户第 4 条:哪些本子显示,改由设置里的复选框决定(以前是左侧那一栏)。
|
||||
// 藏掉的本子**不参与取数**,而不是"取出来再 display:none" ——
|
||||
// 前端藏的话,关掉一本之后月视图的"+N 条"仍然按它算,数字对不上。
|
||||
$hiddenCals = Calendar::hiddenCalendars($me);
|
||||
$visCals = [];
|
||||
foreach ($cals as $c) {
|
||||
if (!isset($hiddenCals[(int)$c['id']])) {
|
||||
$visCals[] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
if ($view === 'month') {
|
||||
$gym = trim((string)($_GET['ym'] ?? ''));
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $gym)) {
|
||||
$ym = $gym;
|
||||
}
|
||||
[$from, $to] = month_grid_range($ym);
|
||||
$events = Calendar::month($familyId, $visCals, $me, $from, $to);
|
||||
$grid = build_month_grid($ym, $events);
|
||||
} else {
|
||||
// 周视图 7 列、天视图 1 列,共用同一个时间网格
|
||||
$cols = day_columns($anchor, $view === 'week' ? 7 : 1);
|
||||
$from = $cols[0]['date'];
|
||||
$to = $cols[count($cols) - 1]['date'];
|
||||
$events = Calendar::month($familyId, $visCals, $me, $from, $to);
|
||||
$grid = build_time_grid($events, $cols);
|
||||
}
|
||||
|
||||
// 用户第 5 条:在日历里点一下就建日程,**开始时间 = 点的那一格**。
|
||||
// 点空白处只带日期/时刻过来,标题等还是空的(不能替用户瞎填)。
|
||||
$newAt = trim((string)($_GET['new_at'] ?? ''));
|
||||
|
||||
/* ---- 当前时间红线(v24 第 20 批 用户第 6 条)----
|
||||
用户原话:「周视图和日视图,应该在视图中间显示一条红线…来表示当前时间
|
||||
在视图上的刻度和位置。」
|
||||
⚠️ 位置在**服务端**算好(与 .tg-block 的 top/height 同一套算法:
|
||||
列高固定 24 小时 → 1 分钟 = 100/1440 %),前端只负责"过一会儿自己挪一下"。
|
||||
不在 CSS 里写死时间,也不在前端算 —— 算法只有一处,不会两边对不上。 */
|
||||
$nowMinutes = ((int)date('G')) * 60 + (int)date('i');
|
||||
$nowTop = round($nowMinutes / 1440 * 100, 4);
|
||||
/* 🔵 工单 #37:取数 + 建网格整套逻辑抽到 `calendar_view_data()`。
|
||||
整页渲染与「新建/修改后的局部重绘」**必须**用同一个函数 ——
|
||||
两处各算一遍必然有一天只改到一半("整页对、片段错"或反之),
|
||||
而那类 bug 表现为"改完刷新一下才显示对",极难被发现。
|
||||
⚠️ 参数从 `$_GET` 取还是从别处取,交给函数自己决定(它认 array 入参)。 */
|
||||
$cd = calendar_view_data($familyId, $me);
|
||||
$view = $cd['view'];
|
||||
$ym = $cd['ym'];
|
||||
$anchor = $cd['anchor'];
|
||||
$nowTop = $cd['nowTop'];
|
||||
$newAt = $cd['newAt'];
|
||||
$cals = $cd['cals'];
|
||||
$visCals = $cd['visCals'];
|
||||
$hiddenCals = array_flip($cd['hiddenCals']);
|
||||
$cols = $cd['cols'];
|
||||
$weeks = $cd['weeks'];
|
||||
$tgrid = $cd['tgrid'];
|
||||
|
||||
View::render('calendar/index', [
|
||||
'title' => '日程',
|
||||
@@ -5388,17 +5480,68 @@ $want = array_keys($want);
|
||||
'view' => $view,
|
||||
'anchor' => $anchor,
|
||||
'nowTop' => $nowTop,
|
||||
'cols' => $view === 'month' ? [] : ($cols ?? []),
|
||||
'weeks' => $view === 'month' ? $grid : [],
|
||||
'tgrid' => $view === 'month' ? null : $grid,
|
||||
'cols' => $cols,
|
||||
'weeks' => $weeks,
|
||||
'tgrid' => $tgrid,
|
||||
'cals' => $cals,
|
||||
'visCals'=> array_map('intval', array_column($visCals, 'id')),
|
||||
'hiddenCals' => array_keys($hiddenCals),
|
||||
'hiddenCals' => $cd['hiddenCals'],
|
||||
'newAt' => $newAt,
|
||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||
], 'calendar');
|
||||
break;
|
||||
|
||||
/**
|
||||
* 只回**日历视图区**的片段(工单 #37)。
|
||||
*
|
||||
* 用户原话:「修改和新建,都不应该刷新页面,而是使用js动态调整。」
|
||||
* ⇒ 新建 / 修改成功后,前端按当前视图来这里取一份"服务端渲染好的视图",
|
||||
* 整块替换页面上的 `[data-cal-view]`。**不手写复刻渲染**,所以
|
||||
* "插进去的样子"必然等于"刷新一次的样子"。
|
||||
*
|
||||
* 参数与 `calendar` 路由完全一致(view / d / ym),所以前端只要把当前
|
||||
* URL 的这几个参数原样带回来即可。
|
||||
* ⚠️ 用 `View::renderPartial`,不套 layout —— 塞进 innerHTML 的只能是片段。
|
||||
*/
|
||||
case 'calendar/view':
|
||||
Auth::requireLogin();
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
$cd = calendar_view_data($familyId, $me);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo View::renderPartial('calendar/_view', [
|
||||
'view' => $cd['view'],
|
||||
'nowTop' => $cd['nowTop'],
|
||||
'cols' => $cd['cols'],
|
||||
'weeks' => $cd['weeks'],
|
||||
'tgrid' => $cd['tgrid'],
|
||||
'canNew' => (bool)$cd['canNew'],
|
||||
]);
|
||||
exit;
|
||||
|
||||
/**
|
||||
* 只回**首页日程卡片**的片段(工单 #37)。
|
||||
*
|
||||
* 首页「新建日程」/ 浮层里改完日程之后,卡片要立刻反映出来。
|
||||
* 与 `calendar/view` 同一套思路:取服务端渲染好的片段整块替换,
|
||||
* 不手写复刻 —— 卡片里那套「掐头去尾 / 红线定位 / 冲突分列」的算法
|
||||
* 复刻一遍必然对不上(`_card_agenda.php` 里记着 v80/v85 两次由算法不一致
|
||||
* 引起的真实事故)。
|
||||
*/
|
||||
case 'home/agenda':
|
||||
Auth::requireLogin();
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
$today = date('Y-m-d');
|
||||
$todayEvents = home_agenda_events_live($familyId, $me, $today);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo View::renderPartial('home/_card_agenda', [
|
||||
'todayEvents' => $todayEvents,
|
||||
]);
|
||||
exit;
|
||||
|
||||
/**
|
||||
* 保存「日程显示设置」(v24 用户 2026-10-03 第 4、5 条)—— 每人一份。
|
||||
*
|
||||
@@ -6040,6 +6183,40 @@ $want = array_keys($want);
|
||||
$me = (int)Auth::personId();
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
/* 🔵 工单 #37(用户 2026-11-01)——**用户原话**:
|
||||
「修改和新建,都不应该刷新页面,而是使用js动态调整。」
|
||||
|
||||
同一个路由要服务两种调用者 ⇒ 与 `event_add` / `event_del` 同一套约定,
|
||||
认 `X-Requested-With`:
|
||||
· 条目浮层的「保存」带它 → 回 JSON,前端就地更新当前视图;
|
||||
· 无 JS 的原生表单 → 照旧 flash + 302(渐进增强的兜底不变)。
|
||||
⚠️ 改动前这条路由**一条 AJAX 出口都没有**:浮层里编辑完必然整页跳走,
|
||||
而跳去的是 `calendar?ym=<starts_at 的月份>` —— 在首页 / 周视图里改一条
|
||||
别的月份的日程时,刷新后连原视图都不在了,用户根本看不出改成功没有。
|
||||
⚠️ 判据不能用「有没有某个字段」之类的猜测 —— 两条路的参数完全一样。 */
|
||||
$ajax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest')
|
||||
|| (strpos((string)($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json') !== false);
|
||||
/* 失败出口:AJAX 回 JSON(可预期的拒绝不该把用户甩到一个刷新过的页面上)。 */
|
||||
$evFail = static function (string $msg) use ($ajax, &$ym) {
|
||||
if ($ajax) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'msg' => $msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('err', $msg);
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
};
|
||||
/* 成功出口:AJAX 回 JSON 并 exit;非 AJAX 只记 flash,**不 exit** ——
|
||||
调用处后面的 `redirect('calendar', [...])` 照旧执行,与原行为完全一致。
|
||||
`$extra` 带"前端要据此决定重绘范围"的标记(如 `moved_series`)。 */
|
||||
$evOk = static function (string $msg, array $extra = []) use ($ajax) {
|
||||
if ($ajax) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => true, 'msg' => $msg] + $extra, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('ok', $msg);
|
||||
};
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$ym = trim((string)($_POST['ym'] ?? ''));
|
||||
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
|
||||
@@ -6055,19 +6232,16 @@ $want = array_keys($want);
|
||||
$ev = $st->fetch();
|
||||
$ev2 = $ev; // 旧内容(scope 分支要用,别被后面的 $d 覆盖)
|
||||
if (!$ev) {
|
||||
flash('err', '找不到这条日程。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
$evFail('找不到这条日程。');
|
||||
}
|
||||
$cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
|
||||
if (!Calendar::canWrite($cal, $me)) {
|
||||
flash('err', '这本日历你没有写权限。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
$evFail('这本日历你没有写权限。');
|
||||
}
|
||||
// v24 第 5、6 条:起止各是一个完整时刻(精确到秒),见上面 event_add 的说明。
|
||||
$startsAt = read_dt_input('starts_at');
|
||||
if ($startsAt === null) {
|
||||
flash('err', '开始时间不对。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
$evFail('开始时间不对。');
|
||||
}
|
||||
$endsAt = read_dt_input('ends_at');
|
||||
$allDay = !empty($_POST['all_day']);
|
||||
@@ -6112,7 +6286,10 @@ $want = array_keys($want);
|
||||
['id' => $id, 'calendar_id' => (int)$ev['calendar_id']],
|
||||
$occDay, $d, $me
|
||||
);
|
||||
flash('ok', '只改了这一次。');
|
||||
/* ⚠️ `moved_series`:这一档除了改本条,还在系列里**新落了一条**
|
||||
(原系列跳掉这一天)。前端据此知道整条系列都变了,
|
||||
重绘范围要覆盖所有可能出现这一天的地方,不能只改那一个块。 */
|
||||
$evOk('只改了这一次。', ['moved_series' => true]);
|
||||
} else {
|
||||
// 这一次及以后:老系列截到前一天,新系列从这天起
|
||||
$rp = Repeat::parse($rruleOld);
|
||||
@@ -6143,7 +6320,8 @@ $want = array_keys($want);
|
||||
unset($offsetDays);
|
||||
$d2['created_by'] = $me;
|
||||
Calendar::addEvent((int)$ev['calendar_id'], $d2);
|
||||
flash('ok', '这一次及以后的都改好了(过去的保持不变)。');
|
||||
$evOk('这一次及以后的都改好了(过去的保持不变)。',
|
||||
['moved_series' => true]);
|
||||
}
|
||||
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
|
||||
}
|
||||
@@ -6153,6 +6331,40 @@ $want = array_keys($want);
|
||||
if ($warns) {
|
||||
flash('err', '有附件没有保存:' . implode(';', $warns));
|
||||
}
|
||||
/* 🔵 工单 #37:AJAX 时回**够前端整块重绘当前视图**的信息。
|
||||
⚠️ 为什么不回"新那条的字段让前端自己插进 DOM"(`event_add` 那条老路子):
|
||||
那条路子要求前端**手写复刻服务端渲染**(月视图塞小条按时间插、周/日视图
|
||||
按 24h 算 top/height、冲突重算分栏…),`putMonth` 的注释里就记着一次
|
||||
真实事故(新加一条 07:00 的日程被 append 到末尾 ⇒ 落进「+N 条」看不见,
|
||||
刷新后又跑到第一位)。修改要处理的情况更多(跨天变了 / 全天状态变了 /
|
||||
换了日历本改了颜色 / 改了重复规则),复刻必然出错。
|
||||
⇒ 改成"回一个信号,前端按当前视图去取服务端渲染好的片段来替换",
|
||||
只有一处渲染,插进去的样子**必然**等于刷新一次的样子。
|
||||
这里给的字段是给"提示行 + 兜底"用的(比如前端要知道改到哪一天了)。 */
|
||||
if ($ajax) {
|
||||
$newDay = substr((string)$startsAt, 0, 10);
|
||||
$newTm = ($allDay || $startsAt === '') ? '' : substr((string)$startsAt, 11, 5);
|
||||
$newEtm = ($allDay || $endsAt === null || $endsAt === '') ? '' : substr((string)$endsAt, 11, 5);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'msg' => $warns ? '已保存,但有附件没传上去。' : '日程已保存。',
|
||||
'warns' => $warns,
|
||||
'event' => [
|
||||
'id' => (int)$id,
|
||||
'title' => (string)$d['title'],
|
||||
'starts_at' => (string)$startsAt,
|
||||
'ends_at' => (string)($endsAt ?? ''),
|
||||
'all_day' => $allDay ? 1 : 0,
|
||||
'date' => $newDay,
|
||||
'time' => $newTm,
|
||||
'end_time' => $newEtm,
|
||||
'cal' => (int)$ev['calendar_id'],
|
||||
'ym' => substr($newDay, 0, 7),
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('ok', '日程已保存。');
|
||||
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user