v24 第 15 批:活动 / 旅程两级语义(术语 · 派生时间城市 · 表单三项 · 题图可点)
用户原话四条,逐条落地:
1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
→ 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`;
活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」;
节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。
(导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。)
2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的
旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」
→ 活动**不再存**这三样,全部**读时算出来**:
· Trip::derived() 最早开始 / 最晚结束 / 城市去重
· Trip::cities() 按最早出现时间排(先后顺序本身是信息,不是字母序)
· Trip::attachDerived() 列表页批量补(避免 N+1)
· Trip::activitySpan() 没有旅程时说「还没有旅程」,不用「日期待补」
create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。
列表排序/搜索、首页「最近去了」改走子查询。
3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug**
「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用);
第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS,
于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。
→ 做成声明式通用绑定:图上写 data-pick-input="#fileInputId",
app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。
三处(头像 / 题图 / 图书封面)全挂上。
4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
→ 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来,
不说清用户会满页找"怎么没地方填时间")。
备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。
顺带修掉两处会炸的实现问题:
· ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、
php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。
· e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期,
于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。
验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 ·
交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 ·
观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、
城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许
再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。
部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。
This commit is contained in:
+129
-42
@@ -82,7 +82,16 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
如果是周视图,那么就是跳转到这一天所在的周,如果是月视图,则跳转到这一天所在的月」
|
||||
· 「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了」
|
||||
→ ① 不再固定 view=day:带上**当前视图**(月/周/日各自跳到对应跨度);
|
||||
② 日期选择从"居中弹窗"改成**贴着按钮下方展开的下拉日历**。 */ ?>
|
||||
② 日期选择从"居中弹窗"改成**贴着按钮下方展开的下拉日历**。
|
||||
|
||||
🔴 2026-10-05 第 14 批 #1(用户报严重 bug):「点上一月,页面直接跳转,
|
||||
我还没选择日期呢」。根因是这一版下拉里挂的是 Semi 的 DatePicker ——
|
||||
它的日历面板**渲染在 body 的 portal 里**(不在下拉容器内),
|
||||
而它在面板上翻月时会把"面板当前显示的日期"当成新值回调 onChange,
|
||||
于是"翻月"被当成了"选中"。第三方控件把"翻页"与"选中"混在一个回调里,
|
||||
这个语义我们改不了。
|
||||
→ 改成**自绘内联日历**(几十行、零依赖):翻月/翻年只重绘面板、绝不提交,
|
||||
只有点**某一天**才跳转,语义 100% 由我们自己定。 */ ?>
|
||||
<form class="cal-jump" method="get" action="index.php">
|
||||
<input type="hidden" name="r" value="calendar">
|
||||
<?php /* 保留当前视图:月→该月 / 周→那一周 / 日→那一天。
|
||||
@@ -102,8 +111,8 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
<path d="M2 6.6h12M5.2 1.8v2.6M10.8 1.8v2.6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<?php /* 下拉日历的宿主:紧贴按钮下方展开(不是弹窗)。JS 就绪后把 Semi
|
||||
日期选择器挂进来;JS 不可用时它是空的、退到下面的 noscript。 */ ?>
|
||||
<?php /* 下拉日历的宿主:紧贴按钮下方展开(不是弹窗)。内容由下面那段 JS 画;
|
||||
JS 不可用时它是空的、退到下面的 noscript(原生日期框 + 跳转按钮)。 */ ?>
|
||||
<div class="cal-jump-drop" id="calJumpDrop" hidden></div>
|
||||
<noscript>
|
||||
<input class="input" type="date" name="d" value="<?= e($anchor) ?>" aria-label="选择日期">
|
||||
@@ -352,10 +361,21 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
|
||||
})();
|
||||
|
||||
/* ---- v24 第 12 批 #3:「跳到某一天」—— 单个图标按钮 + **按钮下方的下拉日历** ----
|
||||
用户原话:「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了。」
|
||||
所以从"居中弹窗(cal-jump-host 全屏遮罩)"改成"贴着按钮下方展开的下拉"。
|
||||
⚠️ 中途会经过"保留视图"那一关:jump() 提交时带上当前 view(月的 ym / 周的 d / 日的 d),
|
||||
/* ---- v24 第 14 批 #1:「跳到某一天」—— 图标按钮 + **按钮下方自绘的下拉日历** ----
|
||||
原话(两条,都要满足):
|
||||
· 「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了」
|
||||
· 「我点击上一个月,页面直接跳转,我还没选择日期呢」(严重 bug)
|
||||
|
||||
🔴 为什么**不用** Semi 的 DatePicker(上一版就是它):
|
||||
Semi 的日历面板渲染在 **body 的 portal** 里(不在我们的下拉容器内),
|
||||
而且它把"翻月/翻年"与"选中某天"混在同一个 onChange 回调里 ——
|
||||
于是点"上一月"也被当成"选中了这一天",页面立刻提交跳转。
|
||||
第三方控件的这个语义我们改不了;而这个交互又是"翻月不跳、点天才跳",
|
||||
必须 100% 由我们自己定。所以这里自绘:
|
||||
· « ‹ 年/月 › » → **只重绘面板**,绝不提交;
|
||||
· 点某一天 → 立刻提交跳转(保留当前视图);
|
||||
· 底部「跳到这天」→ 明确的确认入口(键盘/读屏用户也能用)。
|
||||
⚠️ jump() 提交时带上当前 view(月的 ym / 周、日的 d),
|
||||
由服务端决定该视图下这个日期落在哪一屏 —— 见 index.php 的 calendar 路由。 */
|
||||
(function () {
|
||||
var form = document.querySelector('.cal-jump');
|
||||
@@ -363,8 +383,14 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
var hid = document.getElementById('calJumpD');
|
||||
var drop = document.getElementById('calJumpDrop');
|
||||
if (!form || !btn || !hid) { return; }
|
||||
var root = null; // React root(重开时先 unmount)
|
||||
|
||||
var WD = ['一', '二', '三', '四', '五', '六', '日']; // 与月视图一致:周一起
|
||||
var curY = 0, curM = 0; // 面板当前显示的年月
|
||||
var sel = ''; // 已选日期('Y-m-d')
|
||||
|
||||
function ymd(y, m, d) {
|
||||
return y + '-' + String(m).padStart(2, '0') + '-' + String(d).padStart(2, '0');
|
||||
}
|
||||
function jump(v) {
|
||||
if (!v) { return; }
|
||||
hid.value = v;
|
||||
@@ -373,54 +399,115 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
if (ym) { ym.value = String(v).slice(0, 7); }
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function closeDrop() {
|
||||
if (!drop) { return; }
|
||||
drop.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
if (root) { root.unmount(); root = null; }
|
||||
drop.innerHTML = '';
|
||||
}
|
||||
|
||||
// 原生兜底:Semi 没就绪时用临时原生日期框(与无 JS 时行为一致)
|
||||
function fallback() {
|
||||
var t = document.createElement('input');
|
||||
t.type = 'date';
|
||||
t.value = hid.value;
|
||||
t.style.position = 'fixed';
|
||||
t.style.left = '-9999px';
|
||||
document.body.appendChild(t);
|
||||
t.addEventListener('change', function () { jump(t.value); });
|
||||
if (typeof t.showPicker === 'function') { try { t.showPicker(); } catch (e2) {} }
|
||||
else { t.focus(); t.click(); }
|
||||
/** 画一个月:**只动面板内容**,不做任何跳转 */
|
||||
function paint() {
|
||||
var head = document.createElement('div');
|
||||
head.className = 'cjd-head';
|
||||
head.innerHTML =
|
||||
'<button type="button" class="cjd-nav" data-step="-12" title="上一年" aria-label="上一年">«</button>' +
|
||||
'<button type="button" class="cjd-nav" data-step="-1" title="上一月" aria-label="上一月">‹</button>' +
|
||||
'<span class="cjd-title"></span>' +
|
||||
'<button type="button" class="cjd-nav" data-step="1" title="下一月" aria-label="下一月">›</button>' +
|
||||
'<button type="button" class="cjd-nav" data-step="12" title="下一年" aria-label="下一年">»</button>';
|
||||
head.querySelector('.cjd-title').textContent = curY + ' 年 ' + curM + ' 月';
|
||||
|
||||
var grid = document.createElement('div');
|
||||
grid.className = 'cjd-grid';
|
||||
for (var w = 0; w < 7; w++) {
|
||||
var wd = document.createElement('div');
|
||||
wd.className = 'cjd-wd';
|
||||
wd.textContent = WD[w];
|
||||
grid.appendChild(wd);
|
||||
}
|
||||
// 起算:本月 1 号是周几(周一=0 … 周日=6),前面补上月尾巴
|
||||
var first = new Date(curY, curM - 1, 1);
|
||||
var lead = (first.getDay() + 6) % 7;
|
||||
var days = new Date(curY, curM, 0).getDate();
|
||||
var today = new Date();
|
||||
var todayStr = ymd(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
var start = new Date(curY, curM - 1, 1 - lead);
|
||||
for (var i = 0; i < 42; i++) {
|
||||
var dt = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||||
var s = ymd(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
|
||||
var cell = document.createElement('button');
|
||||
cell.type = 'button';
|
||||
cell.className = 'cjd-day';
|
||||
if (dt.getMonth() + 1 !== curM) { cell.className += ' is-out'; }
|
||||
if (s === todayStr) { cell.className += ' is-today'; }
|
||||
if (s === sel) { cell.className += ' is-on'; }
|
||||
cell.textContent = String(dt.getDate());
|
||||
cell.setAttribute('data-date', s);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
var foot = document.createElement('div');
|
||||
foot.className = 'cjd-foot';
|
||||
foot.innerHTML =
|
||||
'<span class="cjd-sel small muted"></span>' +
|
||||
'<button type="button" class="btn btn-sm cjd-today">今天</button>' +
|
||||
'<button type="button" class="btn btn-sm cjd-go">跳到这天</button>';
|
||||
foot.querySelector('.cjd-sel').textContent = sel ? ('已选 ' + sel) : '点一天就跳过去';
|
||||
|
||||
drop.innerHTML = '';
|
||||
drop.appendChild(head);
|
||||
drop.appendChild(grid);
|
||||
drop.appendChild(foot);
|
||||
|
||||
// 翻月 / 翻年:**只重画**
|
||||
// ⚠️ 必须 stopPropagation:paint() 会把面板里的节点换掉,等这次 click 冒泡到
|
||||
// document 上的「点外面就关」时,e.target 已经不在 DOM 里了 ——
|
||||
// `drop.contains(e.target)` 为假 → 面板当场被关掉(实测:点一下"上一月",
|
||||
// 面板立刻消失,看起来像"按钮没反应")。
|
||||
Array.prototype.forEach.call(drop.querySelectorAll('.cjd-nav'), function (b) {
|
||||
b.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var step = parseInt(b.getAttribute('data-step'), 10);
|
||||
var t = new Date(curY, curM - 1 + step, 1);
|
||||
curY = t.getFullYear();
|
||||
curM = t.getMonth() + 1;
|
||||
paint();
|
||||
});
|
||||
});
|
||||
// 点某一天 → 跳转(这是**唯一**会自动提交的地方)
|
||||
Array.prototype.forEach.call(drop.querySelectorAll('.cjd-day'), function (b) {
|
||||
b.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
sel = b.getAttribute('data-date');
|
||||
jump(sel);
|
||||
});
|
||||
});
|
||||
// 「跳到这天」:给不需要/不方便"点了就跳"的场景一个明确入口
|
||||
drop.querySelector('.cjd-go').addEventListener('click', function (e) {
|
||||
e.stopPropagation(); jump(sel);
|
||||
});
|
||||
drop.querySelector('.cjd-today').addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var t = new Date();
|
||||
sel = ymd(t.getFullYear(), t.getMonth() + 1, t.getDate());
|
||||
jump(sel);
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (!drop) { fallback(); return; }
|
||||
if (!drop) { return; }
|
||||
if (!drop.hidden) { closeDrop(); return; }
|
||||
|
||||
if (!window.SemiUI || !window.SemiUI.DatePicker || !window.ReactDOM) { fallback(); return; }
|
||||
|
||||
var seed = String(hid.value || '').slice(0, 10);
|
||||
var d0 = /^\d{4}-\d{2}-\d{2}$/.test(seed) ? new Date(seed + 'T00:00:00') : new Date();
|
||||
if (isNaN(d0.getTime())) { d0 = new Date(); }
|
||||
curY = d0.getFullYear();
|
||||
curM = d0.getMonth() + 1;
|
||||
sel = ymd(curY, curM, d0.getDate());
|
||||
drop.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
var inner = document.createElement('div');
|
||||
drop.innerHTML = '';
|
||||
drop.appendChild(inner);
|
||||
root = ReactDOM.createRoot(inner);
|
||||
root.render(React.createElement(SemiUI.DatePicker, {
|
||||
type: 'date',
|
||||
format: 'yyyy-MM-dd',
|
||||
defaultValue: hid.value ? new Date(hid.value + 'T00:00:00') : new Date(),
|
||||
// ⚠️ 不用 `open: true`:那会让 Semi 自己弹它的浮层(就又成弹窗了)。
|
||||
// 这里要的是"日历长在下拉容器里",所以让它以内联面板渲染。
|
||||
open: false,
|
||||
defaultOpen: true,
|
||||
preventScroll: true,
|
||||
onChange: function (d, ds) {
|
||||
if (ds) { jump(ds); }
|
||||
}
|
||||
}));
|
||||
paint();
|
||||
});
|
||||
|
||||
// 点外面 / Esc 收起(下拉的常规行为)
|
||||
|
||||
Reference in New Issue
Block a user