v44 第 44 批:界面 5 条 + 移动端 API v1 全量
界面(用户 2026-10-12 第 1~5 条) 1. 新建日程「日历本」并入 .cs 自绘下拉,颜色画进候选项,三字与下拉同行。 2. 新建日程「开始/结束」改「日期范围:」,删旧日期控件; 重复相关三行仅选重复时出现;删「(含第一次)」与「这段时间算忙碌」。 3. 条目浮层加「地点」、去掉「状态」行;首页红线按分钟定位(原只取整小时); 去掉左侧红点只留红线。 4. 任务页删掉状态说明提示(STATUS_HINT 三值清空)。 5. 新建任务:标题在前树框在后;截止时间改最新日历控件;负责人默认自己; 删冗余提示;「上面要做什么」→「任务名称」;清理括号说明。 移动端 API(用户第 6 条:导航模块除外,其余全量) - 新增 Bearer 令牌鉴权(api_tokens 表,Schema v32),与登录口令 / DAV 口令三套分立。 - Api.php 内核(鉴权/取参/信封/分页/分发 + _method 覆写)+ ApiHandler.php 17 条资源 + ApiToken.php + api.php 入口。 - 路由:router.php / .htaccess / index.php 三处 /api/ 兜底(部署脚本自动追加线上规则)。 - 个人设置新增「App 接口令牌」卡;docs/API.md 接口文档; docs/API-QUESTION-LIST.md 自主决策与待确认缺口清单。 验证:本地 e2e 1510/0、v43 真机 33/0、v43 反向 9/0、 v44 真机 41/0、v44 API 63/0、v44 反向 19/0;全流水线 PUSH_RC=0。
This commit is contained in:
+194
-39
@@ -161,51 +161,111 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
<input type="hidden" name="back_view" value="<?= e($view) ?>">
|
||||
<input type="hidden" name="back_anchor" value="<?= e($anchor) ?>">
|
||||
<div class="cal-modal-body">
|
||||
<div class="field full">
|
||||
<label for="ce-cal">放在哪本</label>
|
||||
<?php /* v24 第 21 批 第 7 条:「选择日历本,带上颜色」。
|
||||
⚠️ 原生 <select> 的 <option> **画不出色块**(浏览器不让你在
|
||||
option 里放元素,background 也基本不生效)→ 所以做成
|
||||
「色块 + 下拉」并排:左边一块跟着当前选中的本子变色,
|
||||
右边还是那个原生下拉(保持零依赖、手机上也能滚)。
|
||||
切换时由下面那段 JS 同步色块颜色。 */
|
||||
/* 🔴 v24 第 21 批 第 7 条:**默认必须选"我自己的普通日历本"**。
|
||||
原来直接取 `$writable[0]` —— 而顺序是按 id,观影 / 旅行两本
|
||||
通常排在最前面。于是用户打开"新建日程"、直接打字、点"加上",
|
||||
建出来的其实是一条**观影记录**(或旅程)。
|
||||
这不是"显示不对",是**建错东西**,而且事后很难发现
|
||||
—— 用户以为日历里多了条日程,其实观影列表里多了一条记录。
|
||||
所以:优先把自己建的 kind='custom' 本子排在前面当默认;
|
||||
没有普通本时才退到第一个可写的本子。 */
|
||||
$firstCal = null;
|
||||
foreach ($writable as $c) {
|
||||
if ((string)($c['kind'] ?? '') === 'custom') { $firstCal = $c; break; }
|
||||
}
|
||||
if ($firstCal === null) { $firstCal = $writable ? $writable[0] : null; }
|
||||
/* ⚠️ 模板里**再兜一道**"快照":路由只会在**页面级**补一次本子,
|
||||
而这张表单是页面渲染时定格的 —— 万一补本失败(DB 异常等),
|
||||
这里至少还能看出"没有可写的普通本",不至于把"选中了观影本"
|
||||
当成正常状态。 */
|
||||
$hasPersonal = $firstCal !== null
|
||||
&& (string)($firstCal['kind'] ?? '') === 'custom'; ?>
|
||||
<div class="cal-book-row">
|
||||
<span class="cal-book-dot" id="ce-cal-dot"
|
||||
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
|
||||
<select class="select" id="ce-cal" name="calendar_id">
|
||||
<?php /* ⚠️ v44:`.mv-grid` 在这里就打开 —— 下面的「日历本」行
|
||||
与「日期范围」字段块都要走观影式排版(标签右对齐 + 冒号)。
|
||||
原来 `.mv-grid` 是包在字段块那一小段里的,日课本在它**外面**,
|
||||
于是"日历本:下拉"落不到这套排版上(正是用户报的"不在一行")。 */ ?>
|
||||
<div class="mv-grid">
|
||||
<?php /* 🔴🔴 v44(用户 2026-10-12 第 1 条):「新建日程,放在哪本,改成日历本后面加冒号。
|
||||
现在这句话和日历本下拉框还有前面的颜色,仍然不在一行,
|
||||
我建议把颜色放在下拉框里面,就跟电影的影院选择一样,
|
||||
让日历本这三个字和下拉框在一条线上。」
|
||||
|
||||
三件事:
|
||||
① 标签改「日历本」(用户点名要这三个字),冒号由观影式排版的
|
||||
`.mv-row > .mv-lb::after` 给(与观影/任务一致);
|
||||
② **颜色不再是下拉框外的一个色块**,而是像"影院选择"那样
|
||||
**画进下拉里**(每行左边一个该本子的色点)。
|
||||
原生 `<option>` 画不出元素 ⇒ 换成**自定义下拉**
|
||||
(复用全站的 `.cs` 组件:按钮 + 弹出列表 + 搜索),
|
||||
真值仍写进隐藏的 `<input name="calendar_id">`(表单契约不变)。
|
||||
③ 因为换成了一个自绘的下拉,三者天然在同一行 ——
|
||||
不再有"标签一行、色块+下拉下一行"的错位。
|
||||
|
||||
⚠️ 无 JS 兜底:`.cs` 里**仍然保留**一个原生 `<select name="calendar_id">`
|
||||
(视觉收起、但它是真的提交字段)—— JS 没跑时它就是那个下拉,
|
||||
与 `_datetime.php` 的"隐藏 date 当值载体"是同一套思路
|
||||
(v43 那条"没有盒子的元素定位无效"的教训:这里用 `position:absolute;
|
||||
width:1px` 而非 `display:none`,保留可聚焦、可提交)。
|
||||
⚠️ 组件 id 保持 `cinemaPicker/csBtn/csMenu/csDot/csText` **不合适**
|
||||
(那是观影的编号)—— 用 `calPicker/cb*` 一套独立的,
|
||||
免得将来两处互相 getElementById 抢。
|
||||
⚠️ e2e / 断言里按 `#ce-cal` 定位的写法**必须继续成立**:
|
||||
原生 select 的 id 保持 `ce-cal`(只是被视觉收起)。 */ ?>
|
||||
|
||||
<?php /* 🔴 v24 第 21 批 第 7 条:**默认必须选"我自己的普通日历本"**。
|
||||
原来直接取 `$writable[0]` —— 而顺序是按 id,观影 / 旅行两本
|
||||
通常排在最前面。于是用户打开"新建日程"、直接打字、点"加上",
|
||||
建出来的其实是一条**观影记录**(或旅程)。
|
||||
这不是"显示不对",是**建错东西**,而且事后很难发现
|
||||
—— 用户以为日历里多了条日程,其实观影列表里多了一条记录。
|
||||
所以:优先把自己建的 kind='custom' 本子排在前面当默认;
|
||||
没有普通本时才退到第一个可写的本子。 */
|
||||
$firstCal = null;
|
||||
foreach ($writable as $c) {
|
||||
if ((string)($c['kind'] ?? '') === 'custom') { $firstCal = $c; break; }
|
||||
}
|
||||
if ($firstCal === null) { $firstCal = $writable ? $writable[0] : null; }
|
||||
/* ⚠️ 模板里**再兜一道**"快照":路由只会在**页面级**补一次本子,
|
||||
而这张表单是页面渲染时定格的 —— 万一补本失败(DB 异常等),
|
||||
这里至少还能看出"没有可写的普通本",不至于把"选中了观影本"
|
||||
当成正常状态。 */
|
||||
$hasPersonal = $firstCal !== null
|
||||
&& (string)($firstCal['kind'] ?? '') === 'custom'; ?>
|
||||
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">日历本</span>
|
||||
<div class="cs cal-book-cs" id="calPicker" data-open="0">
|
||||
<button type="button" class="cs-btn" id="cbBtn" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="cs-dot" id="cbDot"
|
||||
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
|
||||
<span class="cs-text" id="cbText"><?= e((string)($firstCal['name'] ?? '未选择')) ?></span>
|
||||
<span class="cs-caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div class="cs-menu" id="cbMenu" hidden role="listbox">
|
||||
<div class="cs-list" id="cbList">
|
||||
<?php foreach ($writable as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
|
||||
$isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
|
||||
<?php /* v24 第 21 批 第 7 条:纯派生的本子(农历/节假日/传统节日/节气)
|
||||
列出来但**禁用** —— 直接不列的话,用户会以为"我的本子丢了";
|
||||
列为可选项又会让他白选一次再吃一个错误提示。
|
||||
禁用 + 一句 title 说明,是这个取舍里最不费解的一种。
|
||||
观影 / 旅行**不禁用**:它们可写(加一条 = 建一条观影/旅程)。 */ ?>
|
||||
<button type="button" class="cs-item<?= $ro ? ' is-disabled' : '' ?>"
|
||||
role="option"<?= $ro ? ' disabled' : '' ?>
|
||||
data-id="<?= (int)$c['id'] ?>"
|
||||
data-color="<?= e((string)$c['color']) ?>"
|
||||
data-text="<?= e((string)$c['name']) ?>"
|
||||
<?= $ro ? 'title="系统自动生成,不能直接加日程"' : '' ?>>
|
||||
<span class="cs-dot" style="background: <?= e((string)$c['color']) ?>;"></span>
|
||||
<span><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php /* 一个都没匹配上时露出来(平时 hidden,不占高度) */ ?>
|
||||
<div class="cs-empty" id="cbEmpty" hidden>没有匹配的日历本</div>
|
||||
</div>
|
||||
<?php /* ⚠️⚠️ 这是**真的提交字段**(不是"只给 JS 用"的影子):
|
||||
自定义下拉只是它的外观;JS 没跑时它就是那个原生下拉。
|
||||
视觉收起用 position:absolute + 1px(**不用 display:none**)——
|
||||
后者会让表单校验时不可聚焦(v37 那次的坑)。
|
||||
桌面无 JS 时它贴在按钮下方 1px 处、可点,仍能选。 */ ?>
|
||||
<select class="select cal-book-native" id="ce-cal" name="calendar_id" aria-label="日历本">
|
||||
<?php foreach ($writable as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
|
||||
$isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
|
||||
<?php /* v24 第 21 批 第 7 条:纯派生的本子(农历/节假日/传统节日/节气)
|
||||
列出来但**禁用** —— 直接不列的话,用户会以为"我的本子丢了";
|
||||
列为可选项又会让他白选一次再吃一个错误提示。
|
||||
禁用 + 一句 title 说明,是这个取舍里最不费解的一种。
|
||||
观影 / 旅行**不禁用**:它们可写(加一条 = 建一条观影/旅程)。 */ ?>
|
||||
<option value="<?= (int)$c['id'] ?>"
|
||||
data-color="<?= e((string)$c['color']) ?>"
|
||||
<?= $isDef ? ' selected' : '' ?>
|
||||
<?= $ro ? 'disabled title="系统自动生成,不能直接加日程"' : '' ?>><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php /* 兼容旧断言/旧 JS:外面那个独立色块曾经叫 ce-cal-dot。
|
||||
现在颜色已经画进下拉里、不再需要它 —— 保留一个**隐藏**的同名元素,
|
||||
免得旧探针 document.getElementById('ce-cal-dot').style 直接抛错。 */ ?>
|
||||
<span class="cal-book-dot" id="ce-cal-dot" hidden
|
||||
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!$hasPersonal): ?>
|
||||
<span class="tip" style="display:block;margin-top:6px">
|
||||
⚠️ 你还没有**自己的日程本**,上面选的是一本"派生本"
|
||||
@@ -213,7 +273,6 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
<a href="<?= e(url('calendar/calendars')) ?>">日历本</a> 建一本吧。
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
|
||||
新建时「全天」默认**不勾**(用户原话)。开始时间默认:
|
||||
带了 new_at 就用它(点日历的那一格),否则今天 09:00。
|
||||
@@ -233,12 +292,14 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
$evFields = ['starts_at' => $defDay . ' ' . $defHM . ':00',
|
||||
'ends_at' => $defDay . ' ' . $defEndH . ':' . substr($defHM, 3, 2) . ':00'];
|
||||
$evAllDay = false;
|
||||
/* ⚠️ v44:`.mv-grid` 容器**已经在上面日课本那一行之前打开了**
|
||||
(日课本自己也要走观影式排版)—— 这里只 require 字段块,
|
||||
不要再包一层,否则会出现嵌套的第二个 `.mv-grid`。 */
|
||||
$evMvStyle = true; // 观影式排版(用户第 5 条)
|
||||
echo '<div class="mv-grid">';
|
||||
require __DIR__ . '/_event_fields.php';
|
||||
echo '</div>';
|
||||
unset($evMvStyle);
|
||||
?>
|
||||
</div><?php /* /mv-grid */ ?>
|
||||
</div><?php /* /cal-modal-body */ ?>
|
||||
<div class="cal-modal-foot">
|
||||
<span class="cal-modal-tip" id="calAddTip"></span>
|
||||
@@ -558,6 +619,100 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
syncDot();
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
🔴 v44(用户 2026-10-12 第 1 条):日历本自绘下拉(色点画进下拉里)
|
||||
------------------------------------------------------------
|
||||
与观影的影院下拉同一套交互,但**状态写在选中的原生 `<select>` 上**
|
||||
(写 `.value` + 派发 change)—— 这样无 JS 兜底、既有断言(读 `#ce-cal`)
|
||||
与"提交契约"全都不用改:自定义下拉只是它的**外观**。
|
||||
|
||||
⚠️ 菜单 `.cs-menu` 是 `position:absolute`,而它**居中面板的滚容器里**
|
||||
(`.cal-modal { overflow-y:auto }`)。要看它在低端会不会被裁掉 ——
|
||||
真机几何断言里会量(见论文式验证)。
|
||||
============================================================ */
|
||||
(function () {
|
||||
var picker = document.getElementById('calPicker');
|
||||
var sel = document.getElementById('ce-cal');
|
||||
if (!picker || !sel) { return; }
|
||||
var btn = document.getElementById('cbBtn');
|
||||
var menu = document.getElementById('cbMenu');
|
||||
var dot = document.getElementById('cbDot');
|
||||
var text = document.getElementById('cbText');
|
||||
var list = document.getElementById('cbList');
|
||||
if (!btn || !menu || !dot || !text || !list) { return; }
|
||||
|
||||
/* 把 options 重建成列表项 —— 单一真值来源是那个 `<select>`,
|
||||
所以这里不需要模板另给一份数据、也不会两边不同步。 */
|
||||
function build() {
|
||||
list.innerHTML = '';
|
||||
Array.prototype.forEach.call(sel.options, function (o) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'cs-item' + (o.disabled ? ' is-disabled' : '');
|
||||
b.setAttribute('role', 'option');
|
||||
if (o.disabled) { b.disabled = true; b.title = o.getAttribute('title') || ''; }
|
||||
b.setAttribute('data-id', o.value);
|
||||
b.setAttribute('data-color', o.getAttribute('data-color') || '');
|
||||
b.setAttribute('data-text', o.textContent || '');
|
||||
var d = document.createElement('span');
|
||||
d.className = 'cs-dot';
|
||||
d.style.background = o.getAttribute('data-color') || '';
|
||||
var s = document.createElement('span');
|
||||
s.textContent = o.textContent || '';
|
||||
b.appendChild(d); b.appendChild(s);
|
||||
list.appendChild(b);
|
||||
});
|
||||
}
|
||||
function paint() {
|
||||
var o = sel.options[sel.selectedIndex];
|
||||
if (!o) { return; }
|
||||
text.textContent = o.textContent || '';
|
||||
var c = o.getAttribute('data-color') || '';
|
||||
if (c) { dot.hidden = false; dot.style.background = c; } else { dot.hidden = true; }
|
||||
}
|
||||
function closeMenu() {
|
||||
menu.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
picker.setAttribute('data-open', '0');
|
||||
}
|
||||
function openMenu() {
|
||||
menu.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
picker.setAttribute('data-open', '1');
|
||||
}
|
||||
|
||||
build();
|
||||
paint();
|
||||
/* ⚠️ 原生 select 的 change 会由别处(旧 syncDot、无 JS 路径)触发,
|
||||
这里也监听一次,保证显示与真值永远一致。 */
|
||||
sel.addEventListener('change', paint);
|
||||
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (menu.hidden) { openMenu(); } else { closeMenu(); }
|
||||
});
|
||||
menu.addEventListener('click', function (e) {
|
||||
var it = e.target.closest ? e.target.closest('.cs-item') : null;
|
||||
if (!it || it.disabled) { return; }
|
||||
e.stopPropagation();
|
||||
sel.value = it.getAttribute('data-id');
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
closeMenu();
|
||||
});
|
||||
/* 点空白处收起(用 document 监听 + 判定 inside,不额外造遮罩节点)
|
||||
⚠️ 必须在**捕获阶段**判定:菜单项的 click 会 stopPropagation,
|
||||
但那是冒泡阶段 —— 捕获阶段先于它,这里判"点在不在 picker 里"
|
||||
就够精确了(点在菜单里 → 不收)。 */
|
||||
document.addEventListener('click', function (e) {
|
||||
if (menu.hidden) { return; }
|
||||
if (picker.contains(e.target)) { return; }
|
||||
closeMenu();
|
||||
}, true);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !menu.hidden) { closeMenu(); }
|
||||
});
|
||||
})();
|
||||
|
||||
var form = document.getElementById('calAddForm');
|
||||
var tip = document.getElementById('calAddTip');
|
||||
if (!form || !card || !window.fetch) { return; }
|
||||
|
||||
Reference in New Issue
Block a user