diff --git a/app/Calendar.php b/app/Calendar.php
index 5fc55f1..865229a 100644
--- a/app/Calendar.php
+++ b/app/Calendar.php
@@ -935,6 +935,11 @@ final class Calendar
foreach ($rows as $e) {
$c = $calById[(int)$e['calendar_id']] ?? null;
+ /* 🔴 v49(用户 2026-10-14 第 2 条):条目详情浮层要按来源给按钮 ——
+ 自定义日程给「详情/编辑/删除」,别的模块派生的只给「详情」。
+ 「删除」还要求**我在这本日历上可写** ⇒ 这里就把结论算好带出去
+ (`fb_entry_json()` 读 `_can_write`)。模板里再逐条判一次会重复三处。 */
+ $canW = $c !== null && self::canWrite($c, $personId);
$sd = substr((string)$e['starts_at'], 0, 10);
$ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10);
$tm = substr((string)$e['starts_at'], 11, 5);
@@ -974,6 +979,8 @@ final class Calendar
// 带上这一天的日期,重复日程点进去才能定位到"改哪一次"
'occ' => $day,
'repeat' => $rr,
+ // v49:这一条我能不能改/删(浮层据此决定显不显「编辑/删除」)
+ '_can_write' => $canW,
'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]),
];
$first = false;
diff --git a/app/helpers.php b/app/helpers.php
index c2807e5..4a19671 100644
--- a/app/helpers.php
+++ b/app/helpers.php
@@ -363,6 +363,9 @@ function days_until_birthday(?string $birthday): ?int
* `edit` 「编辑」取哪份编辑表单片段(`?embed=1` 的那些路由)
* `rows` 浮层正文那几行「标签:值」
*
+ * 🔴 v49(用户 2026-10-14 第 2 条)再补三样,见下方 `$payload` 上方的说明:
+ * `source` / `del` / `info`(+ `repeat` / `occ`)—— 让浮层能按来源分形态。
+ *
* ⚠️ 模板里只调这一个函数,不要在各模板里各拼一份 JSON ——
* "三个入口三套写法"必然有一天漏改一个(用户第 6 条点名了三处入口)。
* ⚠️ 返回**已经 json_encode 好的字符串**,直接写进 `data-fb-entry='…'`;
@@ -407,11 +410,39 @@ function fb_entry_json(array $ev, string $detail = '', string $edit = '', array
}
}
+ /* 🔴 v49(用户 2026-10-14 第 2 条)——按来源分两种形态。
+ 用户原话:「点击详细的时候,如果这是一个日程,就是正常的日程里面的一个事件,
+ 不是来自于其他的模块…那么就应该直接在当前的页面,让这个模态框变大,
+ 显示出来更多的信息。…如果当前显示的这个日程是来自于其他的模块,比如观影、
+ 旅程…点击详情的时候就进入到这些模块展示它…这种情况下,下面就没有编辑了,
+ 也没有删除,就只有一个详情。」
+ ⇒ 多带四个字段:
+ `source` 'event'(自定义日程)/ 'derived'(观影·旅程·任务·纪念日·农历·节假日)
+ `del` 删除用的 POST 地址(**只有 source='event' 且可写**时才给)
+ `info` 详情片段地址(`calendar/event?embed=detail&id=…`;source='event' 才有)
+ `repeat` / `occ` 重复日程的删除范围要用("这一次 / 以后 / 全部"靠它定位)
+ ⚠️ 判据是 `kind === 'custom'`(**不是**"有没有 edit"):只有 custom 是
+ calendar_events 里真属于某本日历的自定义日程;其余全是**从别的模块派生**的,
+ 不该在这里改/删(用户明确要求)。
+ ⚠️ `del`/`edit` 为空 ⇒ 浮层自动不显那两颗按钮
+ ⇒ "只读日历里的自定义日程"天然变成"只有详情",前端不用再判一次。 */
+ $evKind = (string)($ev['kind'] ?? 'custom');
+ $evRef = (int)($ev['ref_id'] ?? 0);
+ $evOwn = ($evKind === 'custom' && $evRef > 0);
+ $evW = !empty($ev['_can_write']); // 由调用方按日历权限给出(见模板)
+
$payload = [
'title' => trim((string)($ev['title'] ?? '')),
'rows' => $rows,
'detail' => $detail,
'edit' => $edit,
+ // 新增(v49)
+ 'source' => $evOwn ? 'event' : 'derived',
+ 'ref' => $evOwn ? $evRef : 0, // 日程 id(前端删/改要按它定位,别从 URL 里抠)
+ 'del' => ($evOwn && $evW) ? url('calendar/event_del') : '',
+ 'info' => $evOwn ? url('calendar/event', ['id' => $evRef, 'embed' => 'detail']) : '',
+ 'repeat' => (string)($ev['repeat'] ?? '') !== '',
+ 'occ' => (string)($ev['occ'] ?? ''),
];
return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT);
}
diff --git a/assets/css/app.css b/assets/css/app.css
index b5aeec0..6101d19 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -5765,10 +5765,49 @@ h5.md-h, h6.md-h { font-size: 14px; }
}
.fb-entry-modal[hidden] { display: none; }
.fb-entry-panel { width: min(620px, 100%); max-height: calc(100vh - 64px); }
+/* 🔴 v49(用户 2026-10-14 第 2 条):「点击详情…让模态框**变大**,显示更多信息」。
+ 加宽到 860px(620 的 1.39 倍)—— 详情里可能有多行"标签:值"与附件清单,
+ 宽一点才不至于每行都折行。窄屏仍占满。 */
+.fb-entry-panel.is-wide { width: min(860px, 100%); }
+/* 底栏右侧的按钮组(用户:「三个按钮,都放在下面的右侧」)。
+ `margin-left:auto` 把整组推到右边;左端那句提示(.cal-modal-tip)留在原地。
+ ⚠️ 不用 `.cal-modal-foot .btn[type=submit]{margin-left:auto}` 那条(它只管提交键)。
+ ⚠️ 组内允许换行:窄屏三颗按钮挤不下时要能折到第二行,而不是撑破底栏。 */
+.fb-entry-acts {
+ margin-left: auto;
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;
+}
+/* 🔴🔴 v49:**必须显式声明 `[hidden]`**(与文件前部那条"凡靠 [hidden] 隐藏的都再写一次"
+ 同一条规律)。原因:`.btn { display: inline-flex }` 是**作者样式**,
+ 特异性与优先级都高于 `[hidden]` 的**浏览器默认** `display:none`
+ ⇒ `hidden` 属性对 `.btn` **完全无效**。
+ 症状:底栏四态切换时,前面那些按钮"藏不掉"、全都挤在一条上
+ (本轮实测:派生条目本该只有「详情」,却三颗都露着)。
+ ⚠️ 用 `!important` 与文件前部 `.pick-row[hidden]` 那组的写法保持一致 ——
+ 底栏是"四态互斥"的强语义,宁可压过一切。 */
+.fb-entry-acts .btn[hidden] { display: none !important; }
+/* 三颗的配色(用户:「详细用蓝色,编辑用黄色,删除用红色」)。
+ ⚠️ **作用域类**(fb-a-*),不去改全局 `.btn-edit` / `.btn-danger` 的语义 ——
+ 本项目踩过"裸类改色波及别页"的坑(v41 把影院管理页的蓝色编辑改成了黄,见上面那段)。
+ ⚠️ 三处形态都写(裸类 / a. / button.):`.btn`、`a.btn`、`button.btn` 的特异性不同,
+ 漏一种就有一种是错的(这条规律 v40 已验过)。 */
+.fb-a-detail, a.fb-a-detail, button.fb-a-detail {
+ background: var(--blue); color: #fff; border-color: transparent;
+}
+.fb-a-edit, a.fb-a-edit, button.fb-a-edit {
+ background: var(--yellow); color: #fff; border-color: transparent;
+}
+.fb-a-del, a.fb-a-del, button.fb-a-del {
+ background: var(--red); color: #fff; border-color: transparent;
+}
+.fb-a-detail:hover, .fb-a-edit:hover, .fb-a-del:hover { filter: brightness(.92); }
/* 编辑态下正文里就是一张完整表单(内边距由它自己那份 CSS 给)——
这里只保证它不双份留白。 */
.fb-entry-panel .cal-modal-body > .mv-form-inner,
.fb-entry-panel .cal-modal-body > .nf-wrap { padding: 0; }
+/* 详情展开块:`?embed=detail` 吐的只读 dl(带 .ev-detail-block)。
+ 它自己那份 `.card-pad` 内边距在浮层里是多余的(面板已有 padding)。 */
+.fb-entry-panel .ev-detail-block { padding: 0; }
@media (max-width: 640px) {
- .fb-entry-panel { width: 100%; }
+ .fb-entry-panel, .fb-entry-panel.is-wide { width: 100%; }
}
diff --git a/assets/js/entry_popup.js b/assets/js/entry_popup.js
index 3c1e300..48aa630 100644
--- a/assets/js/entry_popup.js
+++ b/assets/js/entry_popup.js
@@ -1,35 +1,47 @@
/* ============================================================================
- 🔴 v43(用户 2026-10-11 第 6 条)—— 条目详情浮层
+ 条目详情浮层 —— 底栏按「来源」分形态
----------------------------------------------------------------------------
- 用户原话:
- 「首页上的日程、首页上的近期任务,以及日程里面的日程,点击它们的时候,
- 都会弹出一个框显示更详细的信息。上面有不同的功能按钮,比如说详细信息,
- 在日程里面,如果是观影啊、旅程啊,点击详细信息就会进入到观影或者旅程的
- 那个相关的页面。如果是日程的话,点击详细信息就进到日程的详细页面。
- 当然还可以直接点击编辑,点击编辑不同的来源就进入不同的编辑页面,
- 就在当前的页面弹窗,把之前的窗给去掉,然后弹出编辑的那个框来就可以了。」
+ 起源 v43(用户 2026-10-11 第 6 条):首页日程 / 首页任务 / 日程页条目点开弹浮层。
- ⇒ 一个浮层 + 事件委托,服务全站三种入口:
- · 首页「今天的日程」(`.ag-ev` / `.ag-allday-item`)
- · 首页「近期任务」(`.tk-row` 的标题)
- · 日程页月/周/日视图的条目(`.cal-ev` / `.tg-block`)
+ 🔴🔴 v49(用户 2026-10-14)——**用户原话**:
+ 「在首页上点击某一个日程…下面三个按钮,都放在下面的右侧…三个按钮分别是详情、编辑和删除。
+ 详细用蓝色,编辑用黄色,删除用红色。
+ 当点击详细的时候,**如果这是一个日程**(正常的日程里的事件、不是来自观影/旅程等其他模块)
+ → 就**在当前页面让模态框变大**、显示更多信息。
+ 当点击编辑的时候,也是**在当前的界面**弹出编辑日程的框,就地编辑;
+ 编辑完之后下面有两个按钮:**取消和保存**。
+ 当点击删除的时候,就直接把这个日程给删除。
+ 如果当前这个日程**来自其他模块**(观影 / 旅程 / 其它)→ 点击详情就**进入那些模块**展示它
+ (观影→那一天的观影详情页;旅程→那个行程所在的旅程页)。
+ **这种情况下,下面就没有编辑、也没有删除,就只有一个详情。**」
+
+ ⇒ 底栏四态(都在同一排、**一律靠右**):
+ ① detail(默认):`详情`(蓝) + `编辑`(黄) + `删除`(红)
+ · 只有 source='event'(自定义日程)才给「编辑」「删除」;
+ 其余来源(观影/旅程/任务/纪念日/农历/节假日)**只给「详情」**。
+ · 「删除」还要 `del` 非空(= 我在这本日历上可写)。
+ ② info(点「详情」,仅 source='event'):面板加宽、正文换成服务端只读详情。
+ 底栏回到 detail(还能继续编辑/删除)。
+ ③ edit(点「编辑」):正文换成编辑表单;底栏 = `取消` + `保存`。
+ ④ delscope(点「删除」且是**重复日程**):正文问"删哪些";底栏 = `取消` + `确认删除`。
+ 不重复的日程直接删除(不需范围)。
设计取舍(每条都对应一个踩过的坑):
- ① **事件委托挂 document**:列表会被 AJAX 换掉(新建日程后就地插入、
- 首页任务勾选后重画),一次性绑定永久有效(v40 的教训)。
- ② **点条目 = 弹浮层,不跳页**:拦掉 `` 的默认行为。
- ⚠️ 必须按 `[data-fb-entry]` 精确匹配 —— 不能笼统拦 `.cal-ev`:
- 页面里还有"点某天新建日程"那套逻辑(它同样排除 `.cal-ev`)。
- ③ **数据全在 `data-fb-entry` 上**:浮层不发请求就能画出来,秒开;
- 服务端不参与"点开一条"这个动作。
- ④ **「编辑」就地换内容**:取该来源的编辑表单片段(`?embed=1`)。
- 用户原话「把之前的窗给去掉,然后弹出编辑的那个框来」——
- 所以是"正文整块换掉",不是叠第二层浮层。
- ⑤ **保存走原生提交**:四个来源的 update 路由本来就收普通 POST 并跳转,
- 不依赖任何页面自带脚本 ⇒ 浮层在任何页面都能保存成功。
- (自己写 AJAX 保存要为四个来源各写一套"成功后回哪"的规则,得不偿失。
- 原生提交会整页刷新 —— 对"改了数据"这件事来说,刷新一次反而更清楚。)
- ⑥ **Esc 只关最上面那层**:antd 面板开着时先让它收(v38/v39 的坑)。
+ ① **事件委托挂 document**:列表会被 AJAX 换掉(新建日程后就地插入、首页任务勾选后重画),
+ 一次性绑定永久有效(v40 的教训)。
+ ② **点条目 = 弹浮层,不跳页**:拦掉 `` 默认行为。
+ ⚠️ 必须按 `[data-fb-entry]` 精确匹配 —— 不能笼统拦 `.cal-ev`
+ (页面里还有"点某天新建日程"那套逻辑,它同样排除 `.cal-ev`)。
+ ③ **数据全在 `data-fb-entry` 上**:浮层不发请求就能画出来,秒开。
+ ④ **详情/编辑都就地换正文**(用户:「就在当前的页面」)——不叠第二层浮层。
+ ⑤ **保存走原生提交**:四个来源的 update 路由本来就收普通 POST 并跳转
+ (自己写 AJAX 保存要为四个来源各写一套"成功后回哪",得不偿失)。
+ ⑥ **删除走 AJAX**(v49 新增,服务端 `calendar/event_del` 认 X-Requested-With 回 JSON):
+ 成功后就地摘掉那一条,**不刷页** —— 与编辑不同,删除不需要"回到哪一页"的语义。
+ ⚠️ 两种例外退回整页重载:`removed_forward`(这一次及以后砍掉了整个系列尾段,
+ 就地摘一条会漏;且这条系列在视图里可能横跨多天),以及
+ 拿不到 JSON(老服务端 / 出错)。
+ ⑦ **Esc / 点遮罩只关最上面那层**:antd 面板开着时先让它收(v38/v39 的坑)。
⚠️ 本脚本**全站加载**(layout.php):三处入口分散在不同页面,按需加载的判据
会在这些页面之间来回漏;它只有几 KB,不值得为省这点流量赌漏加载。
============================================================================ */
@@ -37,9 +49,10 @@
'use strict';
var modal = null;
- var detailCache = null; /* 详情态的数据(从编辑态「返回」时用它重画) */
+ var detailCache = null; /* 详情态的数据(从编辑态「取消」时用它重画) */
function M() { if (!modal) { modal = document.getElementById('fbEntryModal'); } return modal; }
+ function panelEl(){ return document.getElementById('fbEntryPanel'); }
function bodyEl() { return document.getElementById('fbEntryBody'); }
function titleEl(){ return document.getElementById('fbEntryTitle'); }
function tipEl() { return document.getElementById('fbEntryTip'); }
@@ -58,27 +71,30 @@
t.className = 'cal-modal-tip' + (err ? ' err' : '');
}
- /* ---- 底栏两种形态 --------------------------------------------------------- */
- function footDetail() {
- ['fbEntryDetail', 'fbEntryEdit'].forEach(function (id) {
- var e = byId(id); if (e) { e.hidden = false; }
- });
- ['fbEntryBack', 'fbEntrySave'].forEach(function (id) {
- var e = byId(id); if (e) { e.hidden = true; }
- });
- }
- function footEdit() {
- ['fbEntryDetail', 'fbEntryEdit'].forEach(function (id) {
- var e = byId(id); if (e) { e.hidden = true; }
- });
- ['fbEntryBack', 'fbEntrySave'].forEach(function (id) {
+ /* ---- 底栏:按状态显示哪几颗 ----------------------------------------------
+ 四组 id 各对应一种状态。全部**先隐藏再显示**,避免残留上一态可见的按钮
+ (比如从"编辑态"直接点 Esc 关掉、再开另一条 —— 若不收,"保存"会留在屏幕上)。
+ ⚠️🔴 `detail` 这一态**只列「详情」**:另外两颗(编辑/删除)要不要显,
+ 由 `paintDetail()` **按来源**决定(派生条目只有详情)。
+ 如果把三颗都列在这里,`footShow('detail')` 会把它们无条件点亮,
+ **盖掉** paintDetail 刚设好的 hidden —— 本轮实测就是这么翻车的
+ (观影条目本该只有「详情」,三颗却都露着)。
+ ⇒ 调用顺序也必须配合:**先 footShow('detail')、再设编辑/删除**。 */
+ var GROUPS = {
+ detail: ['fbEntryDetail'],
+ edit: ['fbEntryCancel', 'fbEntrySave'],
+ delscope: ['fbEntryDelCancel', 'fbEntryDelGo']
+ };
+ var ALL_BTNS = ['fbEntryDetail', 'fbEntryEdit', 'fbEntryDelete',
+ 'fbEntryCancel', 'fbEntrySave', 'fbEntryDelCancel', 'fbEntryDelGo'];
+ function footShow(state) {
+ ALL_BTNS.forEach(function (id) { var e = byId(id); if (e) { e.hidden = true; } });
+ (GROUPS[state] || []).forEach(function (id) {
var e = byId(id); if (e) { e.hidden = false; }
});
}
- function footNone() { /* 载入中 / 出错:全都先藏起来 */
- ['fbEntryDetail', 'fbEntryEdit', 'fbEntryBack', 'fbEntrySave'].forEach(function (id) {
- var e = byId(id); if (e) { e.hidden = true; }
- });
+ function footNone() {
+ ALL_BTNS.forEach(function (id) { var e = byId(id); if (e) { e.hidden = true; } });
}
/* ---- 详情态渲染 ----------------------------------------------------------- */
@@ -91,27 +107,50 @@
return html || '这条没有更多信息。
';
}
+ /**
+ * 画详情态。`data` 来自 `data-fb-entry`(服务端 fb_entry_json 产出)。
+ *
+ * 底栏规则(用户第 2 条的核心):
+ * · 「详情」:**恒显**。source='event' 时它就是"就地展开更多信息"(下面用 info 片段);
+ * 其余来源它就是那个跳去模块页的链接(href = detail)。
+ * · 「编辑」「删除」:**只有 source='event'** 才显(且各要有地址:edit / del 非空)。
+ * 这就是用户说的"来自其他模块时,下面就没有编辑、也没有删除,就只有一个详情"。
+ */
function paintDetail(data) {
+ var isEvent = (data.source === 'event');
if (titleEl()) { titleEl().textContent = data.title || '详情'; }
if (bodyEl()) { bodyEl().innerHTML = '' + rowsHtml(data.rows) + '
'; }
+ if (panelEl()) { panelEl().classList.remove('is-wide'); }
+ setTip('');
+
+ /* ⚠️ 顺序要紧:**先** footShow('detail')(它会把所有按钮收掉、只点亮「详情」),
+ **再**按来源点亮/收起「编辑」「删除」。反过来会被 footShow 盖掉。 */
+ footShow('detail');
+
var det = byId('fbEntryDetail');
if (det) {
- if (data.detail) { det.href = data.detail; det.hidden = false; }
- else { det.hidden = true; }
+ /* ⚠️ 两种「详情」共用同一颗按钮:
+ · source='event' → 就地展开(不设 href,交给 JS,用 data-fb-info 标记)
+ · 其余来源 → 真链接(href 指向该模块的详情/那一天) */
+ if (isEvent && data.info) {
+ det.setAttribute('href', '#');
+ det.setAttribute('data-fb-info', '1');
+ } else {
+ det.removeAttribute('data-fb-info');
+ if (data.detail) { det.href = data.detail; } else { det.removeAttribute('href'); }
+ }
}
+ /* 🔴 用户:「来自其他模块时,下面就没有编辑、也没有删除,就只有一个详情」——
+ 所以这两颗**只对 source='event'** 显(且各要有地址)。 */
var ed = byId('fbEntryEdit');
- if (ed) { ed.disabled = false; ed.hidden = !data.edit; }
- // 「编辑」不可用时别把它留在底栏上(否则点了没反应)
- if (!data.edit) { var e2 = byId('fbEntryEdit'); if (e2) { e2.hidden = true; } }
- setTip('');
- footDetail();
- if (!data.edit) { var e3 = byId('fbEntryEdit'); if (e3) { e3.hidden = true; } }
+ if (ed) { ed.hidden = !(isEvent && data.edit); }
+ var de = byId('fbEntryDelete');
+ if (de) { de.hidden = !(isEvent && data.del); }
}
function restoreDetail() {
if (!detailCache) { return; }
- var d = detailCache;
- paintDetail(d);
+ paintDetail(detailCache);
}
/* ---- 打开 / 关闭 ---------------------------------------------------------- */
@@ -119,7 +158,13 @@
var m = M();
if (!m) { return; }
m.hidden = !on;
- if (!on) { detailCache = null; if (bodyEl()) { bodyEl().innerHTML = ''; } }
+ if (!on) {
+ detailCache = null;
+ if (bodyEl()) { bodyEl().innerHTML = ''; }
+ if (panelEl()) { panelEl().classList.remove('is-wide'); }
+ var m2 = M();
+ if (m2) { m2.classList.remove('is-delscope'); }
+ }
}
function antdOpen() {
@@ -127,7 +172,7 @@
return !!(d && !d.classList.contains('ant-picker-dropdown-hidden'));
}
- /* ---- 编辑片段:取回 → 剥壳 → 塞进正文 → 补跑初始化 ------------------------ */
+ /* ---- 片段:取回 → 剥壳 → 塞进正文 → 补跑初始化 ---------------------------- */
/* innerHTML 不执行脚本(v42 的血泪结论)⇒ 抠出来重跑。 */
function runInlineScripts(container, html) {
var re = /