diff --git a/assets/css/app.css b/assets/css/app.css index dad6561..909312b 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -170,39 +170,207 @@ a:hover { text-decoration: underline; } 否则菜单会顶出屏幕、底下的「保存」按钮够不着。 */ .cal-set .menu-list { min-width: 260px; max-width: min(320px, 92vw); } .cal-set-list { padding: 10px; gap: 0; } -.cal-set-form { display: block; } -.cal-set-block { padding: 6px 2px; } -.cal-set-block + .cal-set-block { border-top: 1px solid var(--line-2); margin-top: 4px; } .cal-set-h { font-size: 12px; color: var(--muted); margin-bottom: 6px; } .cal-set-views { display: flex; gap: 12px; flex-wrap: wrap; } .cal-set-view { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; cursor: pointer; } .cal-set-view input { accent-color: var(--brand); } -/* 本子清单:最多占 42vh,超了内部滚动 —— 菜单本身不滚,否则保存按钮会被推出可视区 */ -.cal-set-books { max-height: 42vh; overflow-y: auto; } -/* 🔴 v51(用户 2026-10-15 第 6 条):显示设置**独立成页之后**, - `.cal-set-books` 那条 `max-height:42vh; overflow-y:auto` 就不该再套用了 —— - 它是"塞在下拉里"时的妥协(下拉不能太高)。整页里再套一个 42vh 的滚动区, - 等于"页面里再开一个小窗",日历本多的时候要**滚动两次**才能看完。 - ⚠️ 用 `.cal-set-page` 作用域覆盖,**不动**上面那条基础规则 —— - 它现在没有别的使用者,但留着万一将来又要在别处(弹框里)用同一套列表。 */ -.cal-set-page .cal-set-books { max-height: none; overflow-y: visible; } -/* 页面上把每一本排成一行(与下拉里同一套行样式,只是不滚动) */ -.cal-set-page .cal-set-book { font-size: 14px; } -.cal-set-book { - display: flex; align-items: center; gap: 7px; padding: 5px 4px; - font-size: 13px; cursor: pointer; border-radius: 6px; white-space: normal; +/* 🔴 v72(用户 2026-10-21)—— 这里原有一段 `.cal-set-books / .cal-set-book* / + .cal-set-page / .cal-set-form|block|foot|links` 的样式,随 `calendar/display` + 整页删除(显隐与管理合并进 `calendar/calendars`)**全部失去了使用者**, + 已在本轮清掉。留着的代价不只是"没用": + · 其中 `.cal-set-page .cal-set-books { max-height:none; overflow-y:visible; }` + 曾被 v51 的反向验证当成"守内滚动"的锚点 —— 规则在、页面没了 ⇒ + 注入照旧命中文本、页面照旧全绿 ⇒ **那条反向验证变成空转**, + 在输出上却看起来"这条断言很结实"(实测踩中,见 `_b51_falsify.py` 注释)。 + · ⇐ 教训:**断言只能锚"当前真有使用者"的规则**;删模板时必须同步删死 CSS, + 否则验证脚本会静默退化成摆设。 + 现在"日历本清单不套内滚动"这件事由 `.cal-books` 守(它才是页面上真实的清单容器)。 */ + +/* ============================================================================ + v72(用户 2026-10-21):日历本「显隐 + 管理」合并成一张清单 + ---------------------------------------------------------------------------- + 用户原话:「日历设置里面日历本显示和日历管理可以放在一起啊。就是把日历本全都 + 列出来,然后显示不显示,加个复选框就行了。另外把下面别人共享给我的 + 也提到上面来,把所有的日历都放在一块,只不过在后面标注出来, + 这个是别人谁谁谁共享给我的?是只读的还是可写的,什么的就可以了呀。」 + + ⇒ 每行从左到右读下来就是一整句答案: + [✓] ■ 名字 [谁·只读/可写] 共享 导出 编辑 删除 + ⚠️ 用 grid 而不是 flex:归属那一列要**对齐**(否则每行标签长短不一时, + 后面的操作按钮会跟着左右乱跳,看着很脏)。 + ⚠️ 窄屏(<640px)改成两行:第一行勾选+色块+名字+归属,第二行操作。 + 44 行日历本在手机上横着排会挤成碎片(用户可能在手机浏览器里管日历)。 + ========================================================================== */ +.cal-books { display: flex; flex-direction: column; gap: 2px; } + +/* 分组分隔线:从"我的"切到"共享给我的" */ +.cal-books-sep { + display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; + margin: 14px 0 6px; padding-top: 12px; + border-top: 1px solid var(--line); + font-size: 13px; font-weight: 600; color: var(--muted); } -.cal-set-book:hover { background: var(--line-2); } -.cal-set-book input { accent-color: var(--brand); flex: 0 0 auto; } -.cal-set-book .color-preview { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; } -.cal-set-book .cal-name { overflow: hidden; text-overflow: ellipsis; } -.cal-set-book .node-badge { margin-left: auto; flex: 0 0 auto; } -.cal-set-foot { margin-top: 10px; } -.cal-set-foot .btn { width: 100%; justify-content: center; } -.cal-set-links { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line-2); } -.cal-set-links a { display: flex; align-items: center; gap: 10px; white-space: normal; } -.cal-set-links a .mi { flex: 0 0 16px; color: var(--muted); } -.cal-set-links a:hover .mi { color: currentColor; } +.cal-books-sep-note { font-size: 12px; font-weight: 400; color: var(--faint); } + +.cal-book-row { + display: grid; + grid-template-columns: 22px 30px minmax(0, 1fr) auto auto; + align-items: center; gap: 10px; + padding: 8px 6px; border-radius: 8px; +} +.cal-book-row:hover { background: var(--line-2); } +/* 关掉的本子整行变淡 —— 一眼看出"这本现在不显示", + 但**不 disable**:点一下就能重新勾上(disable 会让人以为"这行坏了") */ +.cal-book-row.is-off .cal-book-name, +.cal-book-row.is-off .cal-book-dot, +.cal-book-row.is-off .cp-trigger-dot { opacity: .45; } + +.cal-book-show { display: inline-flex; align-items: center; cursor: pointer; } +.cal-book-show input { accent-color: var(--brand); width: 16px; height: 16px; cursor: pointer; } + +.cal-book-dot { + display: inline-block; width: 13px; height: 13px; border-radius: 3px; + border: 1px solid var(--line); justify-self: center; flex: 0 0 auto; +} +.cal-book-name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; } + +.cal-book-tags { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.cal-owner { font-size: 12.5px; color: var(--muted); white-space: nowrap; } +.node-badge-quiet { opacity: .7; } +/* 可写 / 只读两个徽标要**一眼能分**:可写用品牌色,只读用中性灰。 + ⚠️ 别用"同色深浅"区分 —— 用户要的正是在列表里快速扫出"哪些我能动"。 */ +.node-badge.is-write { background: var(--brand-soft, rgba(74, 124, 130, .12)); color: var(--brand-ink, var(--brand)); } +.node-badge.is-read { background: var(--line-2); color: var(--muted); } + +.cal-book-acts { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; } +.cal-book-acts .link-quiet { background: none; border: 0; padding: 0; cursor: pointer; font: inherit; font-size: 13px; } +.cal-book-acts .link-quiet:hover { color: var(--brand); text-decoration: underline; } +.cal-book-acts .link-danger:hover { color: var(--danger, #C0533F); } +.cal-book-acts .inline-form { display: inline; margin: 0; } + +/* 已共享给谁:第二行,靠名字下方 */ +.cal-book-shares { + grid-column: 3 / -1; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + margin-top: 4px; font-size: 12.5px; color: var(--muted); +} +.cal-book-shares-label { color: var(--faint); } +.cal-book-shares .inline-form { display: inline; margin: 0; } +.cal-book-shares .link-quiet { background: none; border: 0; padding: 0; cursor: pointer; font: inherit; font-size: 12.5px; } +.cal-book-shares .link-danger:hover { color: var(--danger, #C0533F); } + +@media (max-width: 640px) { + /* 窄屏:勾选+色块+名字+归属 一行,操作另起一行靠右 */ + .cal-book-row { grid-template-columns: 22px 26px minmax(0, 1fr); row-gap: 6px; } + .cal-book-tags { grid-column: 3 / -1; } + .cal-book-acts { grid-column: 1 / -1; justify-content: flex-start; padding-left: 2px; } + .cal-book-shares { grid-column: 1 / -1; } +} + +/* ============================================================================ + v72:内置调色器(替代 ``,不再弹 Windows 系统取色框) + ---------------------------------------------------------------------------- + 用户原话:「选颜色那个地方点了之后是弹出 Windows 的一个颜色选择框,这个不好, + 我们把它改成内置的,就是就是在网页上弹出一个模态框或者一个什么样的, + 然后选颜色,不要弹 Windows 的,那个一个体验也不好,另外一个也不好看。」 + ========================================================================== */ +.cp-field { display: inline-flex; align-items: center; } + +/* 触发按钮:小色块 + hex 值,长得像"一个可点的胶囊"而不是原生控件 */ +.cp-trigger { + display: inline-flex; align-items: center; gap: 7px; + padding: 3px 9px 3px 4px; border: 1px solid var(--line); border-radius: 999px; + background: var(--surface); cursor: pointer; font: inherit; color: var(--ink); + transition: border-color .15s, box-shadow .15s; +} +.cp-trigger:hover { border-color: var(--brand); } +.cp-trigger:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } +.cp-trigger-dot { + width: 18px; height: 18px; border-radius: 50%; flex: 0 0 auto; + border: 1px solid rgba(0, 0, 0, .12); +} +.cp-trigger-hex { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; color: var(--muted); letter-spacing: .3px; +} +/* 行内(日历本清单里)那个调色器更紧凑一点,别把行撑高 */ +.cal-book-row .cp-trigger { padding: 2px 4px; border-color: transparent; background: transparent; } +.cal-book-row .cp-trigger:hover { border-color: var(--line); background: var(--surface); } +.cal-book-row .cp-trigger-hex { display: none; } +.cal-book-row .cp-trigger-dot { width: 15px; height: 15px; } + +/* 模态框:与 .cal-modal 同一套"遮罩 + 居中面板",但独立命名 + —— 它可能在**没有日历**的页面上也用(影院管理等),别跟日历耦合。 */ +.cp-modal { + position: fixed; inset: 0; z-index: 2000; + display: flex; align-items: center; justify-content: center; + padding: 16px; background: var(--mask, rgba(20, 24, 26, .42)); +} +.cp-modal[hidden] { display: none; } +.cp-modal-panel { + width: min(420px, 100%); max-height: calc(100vh - 48px); + display: flex; flex-direction: column; + background: var(--surface); border-radius: 14px; + box-shadow: 0 18px 48px rgba(0, 0, 0, .22); +} +.cp-modal-head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 16px 10px; font-size: 15px; +} +.cp-modal-body { padding: 0 16px 4px; overflow-y: auto; } +.cp-modal-foot { + display: flex; gap: 10px; justify-content: flex-end; + padding: 12px 16px 16px; +} + +/* 大预览块:让用户看到"日程条上白字压在这个颜色上"的真实效果 */ +.cp-preview { + display: flex; align-items: center; justify-content: space-between; + height: 62px; padding: 0 14px; border-radius: 10px; background: #4A7C82; + color: #fff; transition: background .12s; +} +.cp-preview-chip { font-size: 13.5px; font-weight: 600; } +.cp-preview-hex { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; opacity: .8; +} + +.cp-swatches { + display: grid; grid-template-columns: repeat(8, 1fr); + gap: 8px; margin: 14px 0 12px; +} +.cp-swatch { + aspect-ratio: 1 / 1; border-radius: 8px; border: 2px solid transparent; + cursor: pointer; padding: 0; transition: transform .12s, border-color .12s; +} +.cp-swatch:hover { transform: scale(1.08); } +/* 选中态:白环 + 深色外环,任何底色上都看得见(只用深色环时, + 深色块上的环就"消失"了 —— 那正是"点了没反应"的观感来源之一) */ +.cp-swatch.active { + border-color: var(--surface); + box-shadow: 0 0 0 2px var(--ink); +} +.cp-swatch:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } + +.cp-custom { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-bottom: 8px; } +.cp-custom label { font-size: 13px; color: var(--muted); } +.cp-hash { font-family: ui-monospace, monospace; color: var(--faint); } +.cp-hex-input { width: 96px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.cp-hex-input.is-bad { border-color: var(--danger, #C0533F); } +.cp-bad { font-size: 12px; color: var(--danger, #C0533F); } + +/* 改色成功/失败的小浮条 */ +.cp-toast { + position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%); + z-index: 2100; padding: 9px 16px; border-radius: 999px; + background: var(--ink); color: var(--surface); font-size: 13px; + box-shadow: 0 8px 24px rgba(0, 0, 0, .2); + transition: opacity .3s, transform .3s; +} +.cp-toast.is-err { background: var(--danger, #C0533F); } +.cp-toast.is-out { opacity: 0; transform: translateX(-50%) translateY(6px); } + +/* 模态框开着时锁住背景滚动(否则鼠标滚轮会滚到背后的长清单) */ +body.cp-lock { overflow: hidden; } /* 点了日历空白处就建日程(能力保留,见 templates/calendar/index.php 的 data-new-at)。 🔴 v48(用户 2026-10-14 第 3 条):「现在鼠标放在视图上时,变成了一个十字。 diff --git a/index.php b/index.php index 94416e1..c561bb1 100644 --- a/index.php +++ b/index.php @@ -5240,17 +5240,29 @@ $want = array_keys($want); * ⚠️ 保存仍走既有的 `calendar/prefs`(一条没改)—— 它只是从"下拉里的表单" * 变成"这一页上的表单",提交契约、字段名、服务端逻辑完全不动。 */ + /** + * 显示设置 → **已并入「日历本」页**(v72 用户 2026-10-21)。 + * + * 用户原话:「日历设置里面日历本显示和日历管理可以放在一起啊。就是把日历本全都 + * 列出来,然后显示不显示,加个复选框就行了。」 + * + * ⇒ v51 拆出来的 `calendar/display`(默认视图 + 显示哪些本子)与 + * `calendar/calendars`(本子管理)**合并成一页**:一张清单、每行一个复选框。 + * 那一页现在更完整(显隐 + 归属 + 权限 + 编辑/删除/共享都在一处)。 + * + * ⚠️ 这条路由**必须留着**(302 跳过去,不是删掉): + * · 老书签、浏览器历史里还有 `?r=calendar/display`; + * · `_settings_menu.php` 的四行菜单里「显示设置」曾经指向它; + * · 直接删掉的话点旧链接会走"不存在的路由"兜底,用户看到的是"设置页没了"。 + * ⇒ 与 `calendar/caldav_token` / `caldav_revoke` 那两条旧地址同样处理。 + * + * ⚠️ 查询串要**带过去**(`view`):跳转后「返回日程」还要回到用户原来那个视图, + * 丢了 view 就一律变成月视图(用户改完设置回到日程,发现被换成月视图)。 + */ case 'calendar/display': - $familyId = (int)Auth::familyId(); - $me = (int)Auth::personId(); - $cals = Calendar::mine($familyId, $me); - View::render('calendar/display', [ - 'title' => '显示设置', - 'cals' => $cals, - 'hiddenCals' => array_keys(Calendar::hiddenCalendars($me)), - 'view' => (string)($_GET['view'] ?? Calendar::defaultView($me)), - 'anchor' => (string)($_GET['anchor'] ?? date('Y-m-d')), - ], 'calendar'); + redirect('calendar/calendars', array_filter([ + 'view' => (string)($_GET['view'] ?? ''), + ], static fn($v) => $v !== '')); break; case 'calendar/calendars': @@ -5291,6 +5303,15 @@ $want = array_keys($want); 'shared' => $shared, 'shares' => $shares, 'editing' => $editing, + /* 🔴 v72(用户 2026-10-21):「日历设置里面日历本显示和日历管理可以放在一起。」 + ⇒ 这一页现在同时管**显隐**(原 `calendar/display` 的全部内容) + 与**本子管理**。所以这两项必须一起传进来: + · `hiddenCals` —— 哪些本子没勾(原来是 display 页专属的数据); + · `view` —— 回跳时保持当前视图(display 页本来就有)。 + ⚠️ 别把 `calendar/display` 的取数逻辑复制一份到这里 —— + 那边已经改成 302 跳本页(见该路由),单一来源。 */ + 'hiddenCals' => array_keys(Calendar::hiddenCalendars($me)), + 'view' => (string)($_GET['view'] ?? Calendar::defaultView($me)), 'people' => Person::roster($familyId, $me, 'member', ''), 'cdHas' => $cdHas, 'cdPlain' => $cdPlain, @@ -5326,6 +5347,65 @@ $want = array_keys($want); redirect('calendar/calendars'); break; + /** + * 单本改色(v72 用户 2026-10-21)—— AJAX 专用。 + * + * 用户原话:「这个颜色选择是有问题的,点了之后是没反应的,就是预制的颜色 + * 点了之后没反应的。」 + * ⇒ 真根因不是"预置色没绑事件"这么一层:日历本页把"显隐"做成了**整页一次提交** + * (`show[]`),而改色如果也走那张大表单,一次提交就会带上 N 个同名 `color` + * 字段 ⇒ 服务端只拿最后一个 ⇒ 出现"改了这行、那行也变了"。 + * 所以改色必须**单独一条请求**、只带一本。 + * + * ⚠️ 与 cal_update 的区别(两个都留着,别合并): + * · `cal_update` —— 表单提交(改名字 + 改颜色),成功/失败都 `redirect` + * (浏览器在页面级操作,跳回去看到 flash 是对的); + * · `cal_color` —— AJAX 提交(**只改颜色**),一律回 **JSON** + * (前端要据此决定"保留新颜色"还是"回滚成旧的")。 + * 如果这里也 redirect,fetch 会把整页 HTML 当 json 解析失败 ⇒ 用户看到 + * "改颜色没成功"但其实已经改好了(这类"假失败"最难受)。 + * + * 🔴 权限判据与 cal_update **一字不差**:必须是我自己的、且 kind='custom'。 + * 共享给我的本子(哪怕是"可写")也改不了 —— 颜色是主人的东西 + * (用户明确:「别人共享给我的,不让他编辑」)。 + */ + case 'calendar/cal_color': + header('Content-Type: application/json; charset=utf-8'); + $me = (int)Auth::personId(); + // ⚠️ 这里**不能**用 csrf_check():它失败时输出纯文本并 419, + // fetch 那边 `.json()` 会抛 ⇒ 只看到"网络不通",看不到真正原因。 + $csrfOk = isset($_POST['_csrf'], $_SESSION['_csrf']) + && hash_equals((string)$_SESSION['_csrf'], (string)$_POST['_csrf']); + if (!$csrfOk) { + http_response_code(419); + echo json_encode(['ok' => false, 'error' => '页面已过期,刷新后重试'], JSON_UNESCAPED_UNICODE); + exit; + } + if (!Auth::canWrite()) { + http_response_code(403); + echo json_encode(['ok' => false, 'error' => '只读账号不能改颜色'], JSON_UNESCAPED_UNICODE); + exit; + } + $id = (int)($_POST['id'] ?? 0); + $color = trim((string)($_POST['color'] ?? '')); + $cal = Calendar::find($id); + if (!$cal || (int)$cal['owner_id'] !== $me || (string)$cal['kind'] !== 'custom') { + http_response_code(403); + echo json_encode(['ok' => false, 'error' => '这本日历不能改颜色'], JSON_UNESCAPED_UNICODE); + exit; + } + /* ⚠️ 颜色归一化交给 Calendar::update(内部会走 AvatarColor 的归一化并转大写)。 + 但 update 是"整本更新"(name + color 一起写)⇒ 这里把**原名**带回去, + 否则改个颜色会把名字清成空串(那是个很容易漏的坑:update 的签名是 + `update($id, $name, $color)`,不是"只更新传进来的字段")。 */ + Calendar::update($id, (string)$cal['name'], $color); + $saved = (string)Calendar::find($id)['color']; + echo json_encode([ + 'ok' => true, + 'color' => $saved, + ], JSON_UNESCAPED_UNICODE); + exit; + case 'calendar/cal_del': $me = (int)Auth::personId(); Auth::requireWrite(); diff --git a/templates/_color_picker.php b/templates/_color_picker.php new file mode 100644 index 0000000..cc1a8f9 --- /dev/null +++ b/templates/_color_picker.php @@ -0,0 +1,115 @@ +`,改成纯网页的 + * 模态框:预置色板 + 自定义 hex 输入 + 实时预览。 + * + * ───────────────────────────────────────────────────────────────────── + * 两种用法(靠 `$cpName` 是否为空区分): + * + * **A. 表单模式** —— 值随外层表单一起提交(新建/编辑日历本的弹表单用这个): + * ```php + * $cpName='color'; $cpValue='#4A7C82'; $cpPalette=\App\Calendar::PRESET_CAL_COLORS; + * require __DIR__ . '/../_color_picker.php'; + * ``` + * ⇒ 出 ``。 + * + * **B. 独立模式**(`$cpName` 留空)—— 值**不参与表单提交**,由调用页自己拿去做 AJAX: + * ```php + * $cpName=''; $cpValue=$cal['color']; $cpId='calColor'.$id; + * $cpData=['cal-id'=>$id]; // 附加到 .cp-field 上的 data-* + * require __DIR__ . '/../_color_picker.php'; + * ``` + * ⇒ 出 ``(**无 name**)。调用页监听 `cp:change` 事件。 + * + * 🔴 为什么要 B 模式:日历本清单是"一张大表单管显隐"(`show[]` 整页一次提交), + * 如果每行再塞一个 `name="color"`,一次提交会带上 N 个同名 `color` 字段, + * 服务端只能拿到最后一个 —— 于是"改了第 3 本的颜色,保存时把第 5 本也改了"。 + * 所以行内改色走 AJAX 单独落库,**不混进那张大表单**。 + * + * @var string $cpName 隐藏域 name;空串 = 独立模式(不提交) + * @var string $cpValue 初始值('#RRGGBB') + * @var array $cpPalette 预置色板('#RRGGBB' 列表) + * @var string $cpId 可选,值载体的 id;不给则用 'cp_' + $cpName(独立模式必须给) + * @var string $cpLabel 可选,按钮上的无障碍名 + * @var array $cpData 可选,附加到容器上的 data-*(键 => 值) + */ +$cpName = (string)($cpName ?? 'color'); +$cpValue = strtoupper((string)($cpValue ?? '#4A7C82')); +$cpPalette = array_values(array_filter((array)($cpPalette ?? []), 'is_string')); +$cpData = (array)($cpData ?? []); +$cpLabel = (string)($cpLabel ?? '选择颜色'); +/* 🔴🔴 `$cpId` 必须**先给默认值**再用(2026-10-21 实测踩到): + 原先只有下面那个 `if (!$cpId)` —— 表单模式下调用页**根本不传** `$cpId`, + 于是这里读一个未定义变量 ⇒ `Warning: Undefined variable $cpId` + ⇒ 那条 Warning 被 `display_errors` 打成 HTML **塞进页面正文**: + · 页面上会看见一段错字; + · 更糟的是它出现在 `` **之前**, + 某些布局下会把表单控件的行内关系弄乱; + · 而**页面照样 200、照样能用** ⇒ 只有"查正文里有没有 PHP 错误"的断言能抓到。 + ⇒ 教训:可选参数一律 `?? ''` 兜底,**不要靠 `if (!$x)` 当存在性判断** + (读它那一刻 Warning 就已经产生了)。 + ⚠️ 我的 v72 前端探针当时 47/0 全绿却没抓到它 —— 因为它只查 JS 报错、 + 不查服务端正文。已给探针补上这条(见 `_fb_v72_ui.py` 的「PHP 正文干净」)。 */ +$cpId = (string)($cpId ?? ''); + +if ($cpId === '') { + // 表单模式用 name 派生;独立模式没有 name ⇒ 调用页必须给 id + $cpId = 'cp_' . preg_replace('/[^A-Za-z0-9_]/', '_', $cpName !== '' ? $cpName : 'color'); +} + +/* ⚠️ 初始值本身**不是**合法 hex 时(老数据、手工改库),退回落日色而不是把 + `background: xxx` 直接拼进 style —— 那样会把整条样式声明弄坏, + 旁边几个属性一起失效(用户看到的是"色块变成一个方块")。 */ +if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $cpValue)) { + $cpValue = '#4A7C82'; +} + +$cpDataAttr = ''; +foreach ($cpData as $k => $v) { + $cpDataAttr .= ' data-' . e((string)$k) . '="' . e((string)$v) . '"'; +} +?> +> + + + id="" value=""> + `(原生取色控件)。 + + 那是**没改完的旧写法**,同时踩了两件事: + ① **用户点名要去掉的就是它**(「选颜色那个地方点了之后是弹出 Windows 的 + 一个颜色选择框,这个不好」)—— 留着它等于需求没做; + ② CSS/JS 是照「按钮 + 内部小色点 + hex 文本」设计的: + `.cp-trigger` 是 `inline-flex` 胶囊、`.cp-trigger-dot` / `.cp-trigger-hex` + 是它的子元素、模态框靠 `[data-cp-open]` + `[data-cp-target]` 打开并回填。 + 而原生 `` **不能有子元素**,也没有这两个属性 + ⇒ + · `syncTrigger()` 找不到 `.cp-trigger-dot/hex` ⇒ 改完色**按钮上不刷新**; + · `document.querySelectorAll('[data-cp-open]')` 一个都取不到 + ⇒ 模态框**永远打不开**,用户点到的是浏览器原生取色框。 + ⇒ 现在输出的是 ` + \ No newline at end of file diff --git a/templates/_color_picker_modal.php b/templates/_color_picker_modal.php new file mode 100644 index 0000000..24732b7 --- /dev/null +++ b/templates/_color_picker_modal.php @@ -0,0 +1,253 @@ +`(用户明确不要) + * + * 用户原话:「选颜色那个地方点了之后是弹出 Windows 的一个颜色选择框,这个不好, + * 我们把它改成内置的……不要弹 Windows 的,那个一个体验也不好, + * 另外一个也不好看。」 + * + * `` 的外观与交互**完全由操作系统决定**:Windows 上弹出的是 + * 那个带色轮和 HSV 滑条的老式对话框,macOS 上是另一套,手机上是第三套。 + * ⇒ 站点自己控制不了,也无法与本站的圆角/配色对齐。所以整站不再用它取色。 + * + * ⚠️ **保留自定义 hex 输入**:预置色板只有 16 个,用户想要某个精确颜色时 + * 手输 `#RRGGBB` 是最快的路(也让"从别处复制一个色值"变得可能)。 + * 输入非法时不提交非法值 —— 按钮保持 disabled,输入框边框变红。 + * + * ⚠️ **键盘与焦点**:打开时焦点进模态框、关闭时焦点回触发按钮。 + * 不这么做的话,键盘用户关掉后焦点掉到 `body`,Tab 会从页首重新开始。 + * + * ⚠️ **焦点陷阱(Tab 循环)**:模态框开着时 Tab 不许跑到背后的页面上 —— + * 这是 `aria-modal="true"` 的语义要求,也是"看起来能点但其实点不到"的体验修复。 + * + * @var array $cpPalette 预置色板('#RRGGBB' 列表) + * @var string $cpModalTitle 可选,标题 + */ +$cpPalette = array_values(array_filter((array)($cpPalette ?? []), 'is_string')); +$cpModalTitle = (string)($cpModalTitle ?? '选个颜色'); +if (!$cpPalette) { + // 没有色板就没有"点一下选中"的价值,只剩 hex 输入 —— 但仍然比系统框好看 + $cpPalette = ['#4A7C82']; +} +?> + + \ No newline at end of file diff --git a/templates/calendar/_settings_menu.php b/templates/calendar/_settings_menu.php index b7852c9..6472f71 100644 --- a/templates/calendar/_settings_menu.php +++ b/templates/calendar/_settings_menu.php @@ -23,11 +23,21 @@ * 三处都是"☰ → 一串列表"。 * * 四行(顺序是用户点名的): - * ① 显示设置 → `calendar/display`(默认视图 + 显示哪些本子)★ 本批新增的独立页 - * ② 日历本管理 → `calendar/calendars`(改名/换色/共享/导入) + * ① 显示设置 → **v72 起与「日历本管理」合并**:两行都落到 `calendar/calendars` + * (用 `#cal-books` 锚点区分落点,见下) + * ② 日历本管理 → `calendar/calendars`(显隐 + 改名/换色/共享/导入,全在一页) * ③ 订阅管理 → `calendar/calendars#caldav`(只读订阅进来的外部日历) * ④ DAV 口令 → `settings/me#dav`(日历 + 通讯录共用那一把) * + * 🔴 v72(用户 2026-10-21):「日历设置里面日历本显示和日历管理可以放在一起啊。 + * 就是把日历本全都列出来,然后显示不显示,加个复选框就行了。」 + * ⇒ ① 与 ② **现在是同一页**。但**四行都留着**,原因: + * · 用户 2026-10-15 第 6 条明确点名"下拉就是这四行",少一行他会以为功能没了; + * · 两行落到同一页、用锚点定位到各自那一段 —— 点"显示设置"落在清单、 + * 点"日历本管理"也落在清单,但**用户想找的东西就在那儿**,不用再跳。 + * ⚠️ 千万别因为"合并了"就把菜单砍成三行:那是**把用户的入口拿走**, + * 而他这次要的是"放一起",不是"少一个入口"。 + * * 用法(require 前 set 好): * $setCurrent 当前页 key('view'|'books'|'dav')用来高亮; * '' 也行(比如从旅程/观影页挂过来时没有当前项) @@ -57,7 +67,7 @@ $setIcon = static function (string $name): string {
@@ -65,109 +121,201 @@ $canWrite = \App\Auth::canWrite();
-
-

我自己的

- -

还没有日历本。

- -
- - - - - - - - - - - - - - - -
名字颜色共享给操作
- - - 系统预置 + 套进大表单里(套了就整个失效)。 */ ?> + + + + + + +
+

+ 全部日历本 + 勾上就在日历里画出来;取消勾选只是不显示,数据都还在 +

+ + +

还没有日历本。

+ +
+ $c): ?> + + +
+ 别人共享给我的 + 只读的不能改,要改请找主人 +
+ + +
+ + + + + + $cid]; + $cpLabel = '改「' . (string)$c['name'] . '」的颜色'; + require __DIR__ . '/../_color_picker.php'; + ?> + + + + + + + + + + + + 系统预置 -
- - - - - - 不共享 - -
- - - - 里,现在
撤了,这里就地给个按钮。 */ ?> - - - - - - - - - -
- - - + + $c['owner_name'] ?? '', + 'my_nickname' => $c['owner_nick'] ?? '', + ]); + ?> + 共享给我 + + + + + + + + + + 不可修改 + + + data-cal-id="" + data-cal-name="">共享 + + 导出 + + 编辑 +
+ + + +
-
-
- - 导出 ics - - 编辑 -
- - - -
- - 不可改 - -
-
+ + + + + + +
+ 已共享给 + + + +
+ + + + +
+ +
+ + +
+ +
+ + +

+ 观影、旅行、有截止时间的任务会自动出现在这里,不用另建本子。 +

+ +
+ + 取消
- + + + + +
+

默认打开时看 + 打开日程页默认落在哪个视图

+
+ + + +
+ $vw): ?> + + +
+
+ +
+
+ 只能导进自定义本 —— 观影/旅行两本是派生的,塞进去就成了孤儿日程。 */ ?>

导入 ics @@ -191,17 +339,11 @@ $canWrite = \App\Auth::canWrite();

- .files` 始终是空的 → 带 required 的提交会被 - 浏览器原生校验拦下,弹「请选择一个文本。」(用户报的就是这句)。 */ - $sdName = 'ics'; $sdId = 'im-file'; $sdAccept = '.ics,text/calendar'; - $sdMultiple = false; $sdRequired = true; $sdStage = false; - require __DIR__ . '/../_stage_drop.php'; ?> + +
@@ -211,12 +353,7 @@ $canWrite = \App\Auth::canWrite();
- +

CalDAV 订阅 @@ -241,42 +378,13 @@ $canWrite = \App\Auth::canWrite();

- -
-

别人共享给我的

-
- - - - - - - - - - - - -
名字谁的我的权限
$c['owner_name'] ?? '', 'my_nickname' => $c['owner_nick'] ?? ''])) ?>
-
-
- - ` 兜底。12 本 ⇒ 整页 5.9 MB / 近万个 input - ⇒ 浏览器与密码管理器逐个扫描,慢得能感觉到。 - 收成一份之后:**一整套选人组件**(1 份 JSON + 1 份 noscript), - 与日历本数无关 —— 再多本日历页也不会再变大。 - - ⚠️ 选人组件是**单选**(`$ppMode='single'`):行上按钮被点开时,用 - `window.ppSetSelected('person_id', 0, panel)` 把它重置成"未选", - 再让用户挑 —— 否则上一次挑的人会留在表单里(看着像"已经选好了")。 */ ?> + ⇒ 浏览器与密码管理器逐个扫描,慢得能感觉到。 */ ?>