diff --git a/app/Calendar.php b/app/Calendar.php index e12037b..e52788f 100644 --- a/app/Calendar.php +++ b/app/Calendar.php @@ -32,6 +32,22 @@ final class Calendar * 而且 kind ≠ 'custom' → Calendar::delete() 天然删不掉(用户要求"不可删除")。 */ public const DEFAULT_KINDS = [ + /* 🔴 v24 第 21 批 第 7 条:**每人必须有"我自己的日程本"** —— 原来这里 + 只有 6 本**派生**本(观影/旅行/农历/节假日/传统节日/节气), + 一本可写的普通本都没有。后果不只是"列表里缺一本": + + · 新建日程的"放在哪本"里全是派生本,默认选中排最前的**观影** → + 用户直接打字点"加上",建出来的其实是一条**观影记录**(不是日程)。 + 这是"建错东西",事后极难发现(日历里看着是多了条日程, + 可观影列表里莫名多了一条记录); + · 想建一条真正的日程得先自己去"日历本"页建一本 —— 而用户根本没被告知。 + + 所以把"我的日程"补进默认本里(kind='custom' = 可写)。 + ⚠️ 幂等:seedDefaults 按 `owner_id + kind + name` 去重, + 老用户升级时(Schema 里会跑 seedDefaultsForFamily)补一本,不会重复建。 + ⚠️ 顺序:`mine()` 是 `ORDER BY kind='custom', position, id` —— + custom 排最前,所以它天然就是"新建日程"的默认本。 */ + 'custom' => ['我的日程', '#4A7C82'], 'movie' => ['观影', '#7A4E8C'], 'trip' => ['旅行', '#3F7D5A'], 'lunar' => ['农历', '#8C6D3F'], @@ -43,9 +59,26 @@ final class Calendar ]; /** 派生型只读日历(内容不从 calendar_events 取,也不能往里加日程) */ - public const READONLY_KINDS = ['movie', 'trip', 'lunar', 'holiday', 'traditional', 'solarterm']; + /** + * **纯派生、完全不能写入**的日历本:内容按日期算出来(农历/节假日/传统节日/节气)。 + * 往这些本子里加日程毫无意义 —— 加了也不会显示(Calendar::month 只从 + * kind='custom' 取 calendar_events),只会留下一堆"看不见的日程"。 + * + * 🔴 v24 第 21 批 第 7 条:这里原来把 `movie` / `trip` 也算进来了, + * 而 `calendar/event_add` 里**紧跟着**就有两段专门处理它们的分支 + * (「已加进观影」/「已加进旅程」)—— 那两段于是**永远走不到**: + * 校验先一步把请求打回去了。用户的日历本选择器里明明列着「观影」和「旅行」 + * (它们是可写的:加一条 = 建一条真正的观影/旅程记录), + * 选中却必定失败 —— 这就是"选了观影本、点了加上、只得到一句错误"的根因。 + * + * ⚠️ 判据要按**能不能写**来分,不是按"是不是派生": + * · lunar / holiday / traditional / solarterm —— 纯算出来,只读; + * · movie / trip —— 也是派生(内容来自观影/旅程表),但**通过各自的 + * 业务路径可写**(日历这边只是把"加一条"转成"建一条记录")。 + */ + public const READONLY_KINDS = ['lunar', 'holiday', 'traditional', 'solarterm']; - /** 这本是不是"派生只读"的(农历/节假日/观影/旅行都算) */ + /** 这本日历的内容是不是"自动生成、不能直接往里写日程" */ public static function isReadonlyKind(string $kind): bool { return in_array($kind, self::READONLY_KINDS, true); diff --git a/app/Trip.php b/app/Trip.php index da354f2..fffc7eb 100644 --- a/app/Trip.php +++ b/app/Trip.php @@ -625,11 +625,35 @@ final class Trip // ---------------- 行程节点 ---------------- - /** 这趟旅程的全部节点(plan / reality 两轨,按 position 排) */ + /** 这趟旅程的全部节点(计划 / 实际 / 未成行,按**开始时间正序**排) */ public static function nodes(int $eventId, int $viewerId = 0): array { + /* 🔴 v24 第 21 批 第 5 条:排序口径改成「按开始时间」。 + 用户原话:「旅程模块,旅程列表,行程列表,都是按照行程的开始时间排序的, + 最新的在最上面,而不是按照编辑修改时间。」 + + ⚠️ 原来排的是 `category, position, starts_at, id`: + `category` 只有 plan/reality 两个值(老的双轨字段,现在由 stage 派生), + `position` 是**默认同一个数**(旧迁移给的多是 0 或 100), + 于是真正决定顺序的只剩 position —— 同一 position 里按 starts_at 排, + 但 plan 组永远排在 reality 组前面。 + 实测线上 #222 就渲染成「返回青岛(10-5) → 出发去菏泽(9-30) → 去姥姥家(10-2)」, + 既不是时间正序也不是倒序 —— 一条时间线上看,位置是"乱"的。 + + ⚠️ 排序方向:**正序**(早的在前)。 + 用户对**旅程列表**的要求是"最新在最上面"(那是列表,见 Trip::all 的 DESC); + 但**行程列表**是一趟旅行内部的行程单 —— 从出发读到返回才符合直觉。 + 这一点已跟用户确认过(第 21 批选的是"时间正序")。 + ⚠️ 两者方向不同是**刻意的**,不是漏改:一个是"挑哪趟旅行", + 一个是"读这趟旅行的行程"。别为了"看起来统一"把其中一个拧过来。 + + ⚠️ 没有开始时间的排在最后(否则 NULL 会被当成最小值顶到最前面)。 + `(starts_at IS NULL)` 在 MySQL/MariaDB 里就是 1/0,用它当第一个键即可。 + ⚠️ `id` 兜底:同一时刻的两段(中转/换乘常常同分)要有稳定顺序, + 否则每次渲染可能换位置。 */ $st = Database::get()->prepare( - 'SELECT * FROM trip_nodes WHERE event_id = ? ORDER BY category, position, starts_at, id' + 'SELECT * FROM trip_nodes WHERE event_id = ? + ORDER BY (starts_at IS NULL), starts_at, id' ); $st->execute([$eventId]); $rows = $st->fetchAll(); diff --git a/assets/css/app.css b/assets/css/app.css index 6086b2c..018daa5 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -588,7 +588,11 @@ textarea.input { min-height: 76px; resize: vertical; } ⚠️ 用 visibility:hidden 而不是 display:none —— 保留元素占位高度, 否则升级完成的那一瞬表单会"跳"一下(高度从 0 变 40px)。 */ html.js .dt-input:not(.is-semi) .dt-native { visibility: hidden; } -html.js .dt-range:not(.is-semi-range) > .dt-input { visibility: hidden; } +/* ⚠️ v24 第 21 批 第 7 条:两端现在各自被 `.field` 包着,所以选择器要跟着下沉一层。 + 写成 `> .dt-input` 的话这条**永远不会命中** → 未升级时原生控件照旧可见, + 与下面那块"占位骨架"叠在一起,会看到两套控件重影。 */ +html.js .dt-range:not(.is-semi-range) .dt-input { visibility: hidden; } +html.js .dt-range:not(.is-semi-range) > .dt-range-label { visibility: hidden; } /* Semi 未就绪前,先给一个同高的"占位骨架",让表单高度在升级前后保持一致 */ html.js .dt-input:not(.is-semi)::after { content: ''; display: block; flex: 1 1 150px; height: 40px; @@ -613,8 +617,12 @@ html.js .dt-range.is-semi-range::before { display: none; } 由**一个** .dt-semi-range 顶上来。未升级(JS 没跑/加载失败)时, 里面两个原生控件照旧各占一行 —— 也就是"功能没变,只是不好看"。 */ .dt-range { display: flex; flex-direction: column; gap: 8px; } +/* 范围控件自己的标签(只有一个,见 _event_fields.php 的说明) */ +.dt-range-label { font-size: 13px; color: var(--muted); font-weight: 500; } .dt-range.is-semi-range { display: block; } .dt-range.is-semi-range > .dt-input { display: none; } /* 两端都让位给范围控件 */ +/* 两端被 .field 包着时的同一条规则(见上面第 21 批的说明) */ +.dt-range.is-semi-range > .field { display: none; } .dt-semi-range { display: none; width: 100%; max-width: 460px; } .dt-range.is-semi-range .dt-semi-range { display: block; } /* Semi 范围输入框:与站内 .input 同高同圆角;宽度撑开一点(要放两个时间) */ @@ -3741,3 +3749,154 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b /* v24 第 20 批 第 8 条:提醒多选的排版(勾选框多了要能换行,别挤成一长条) */ .rem-picks { display: flex; flex-wrap: wrap; gap: 6px 14px; } .rem-pick { font-size: 13px; } + +/* ============================================================ + v24 第 21 批 第 2 条:观影编辑页的**已存图片**要缩成缩略图 + ------------------------------------------------------------ + 用户原话:「观影模块,海报和票根以及照片都带进来了,但是没有缩放, + 把页面撑破了,应该缩放成缩略图。」 + + 🔴 根因:`.mv-slot` 这个类**在样式表里根本不存在**(第 20 批加模板时只写了 + 结构没写样式)。于是里面的 按**原始像素**渲染 —— 海报是 2:3 竖图 + (上传时按 600×900 裁的),就在一个 236px 宽的格子里撑出 + 600×900 + 942px 高,把表单顶破、还让整页横向溢出(实测 docW 1297 > vw 1280)。 + 「带进来了」这个功能是对的,缺的是"把它当成缩略图看"这一层样式。 + + 现在的规格:左侧固定尺寸缩略图(海报 2:3 → 108×162;票根按同一高度裁切), + 右侧「删除这张」按钮,整体是"看得见、不占地方"的一张缩略图。 + ⚠️ `object-fit: cover` 是必须的:票根那种横长条塞进竖框里, + 不用它就会被压扁变形(看着像图坏了)。 + ⚠️ 缩略图高度**写死**、不能再由图片自身比例决定 —— 那正是撑破的原因。 + ============================================================ */ +.mv-slot { + display: flex; align-items: flex-start; gap: 10px; + margin-bottom: 10px; +} +.mv-slot > a { + display: block; flex: 0 0 auto; + width: 108px; height: 162px; overflow: hidden; + border: 1px solid var(--line); border-radius: var(--radius-s); + background: #F1E7D6; +} +.mv-slot > a img { + width: 100%; height: 100%; object-fit: cover; display: block; +} +.mv-slot > .btn { flex: 0 0 auto; align-self: center; } +/* 窄屏:缩略图再小一点,别把"删除这张"挤出去 */ +@media (max-width: 480px) { + .mv-slot > a { width: 84px; height: 126px; } +} + +/* ============================================================ + v24 第 21 批 第 7 条:「点击或拖动进行上传」的那个框 + ------------------------------------------------------------ + 用户原话:「所有上传图片等附件的地方,都不再放按钮,而是放一个框, + 点击这个框实现点击上传按钮的功能,拖动图片进这个框里, + 实现拖动多个上传功能。框里面写一句话:点击或拖动进行上传, + 其他的功能和现在保持一致。」 + + ⚠️ 这是**全新的统一上传入口**,取代了原来的 `` + (那个原生控件长得像输入框、写着"选择文件 未选择任何文件", + 与旁边那个拖放虚线框是**两个**入口 —— 用户看到的就是"到底点哪个")。 + 现在原生框整个 `.sr-only` 藏起来,页面上只有这**一个**框。 + + ⚠️ 落点高亮 `.is-over` 由 app.js 的 bindZone 加(拖动经过时)—— + 与既有的 `.dropzone.is-over` 是同一套动作,只是这个框自己有底色, + 高亮用"描边+底色变亮"表达(不用 outline,避免与自带虚线边重叠成双线)。 + ============================================================ */ +.stage-drop-tip { display: none; } /* 旧的提示位:文案已并进框里,这里留个空占位规则 */ + +.upbox { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 6px; width: 100%; min-height: 96px; box-sizing: border-box; + padding: 16px 14px; cursor: pointer; text-align: center; + border: 1.5px dashed var(--line); border-radius: var(--radius-s); + background: #FDFBF7; color: var(--muted); + transition: background-color .15s ease, border-color .15s ease, color .15s ease; +} +.upbox:hover { border-color: var(--brand-2); background: #FBF6F0; color: var(--brand); } +.upbox:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; } +/* 拖动经过时的高亮(app.js 的 bindZone 加 .is-over) */ +.upbox.is-over { + border-color: var(--brand); border-style: solid; + background: #FDF3EC; color: var(--brand); +} +.upbox-ico { font-size: 22px; line-height: 1; font-weight: 300; } +.upbox-msg { font-size: 13.5px; } +/* 窄屏:框再矮一点,别把表单撑太长 */ +@media (max-width: 480px) { + .upbox { min-height: 78px; padding: 12px; } + .upbox-ico { font-size: 18px; } +} + +/* 空的提示行(暂存清单下面那句"已上传/失败"):没内容时不占地方 */ +.stage-drop-tip:not([hidden]) { display: block; margin-top: 6px; font-size: 12px; color: var(--muted); } + +/* ============================================================ + v24 第 21 批 第 7 条:新建日程的**弹框** + ------------------------------------------------------------ + 用户原话:「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式 + 进行添加,这样不影响当前日历的显示,添加完成后,动态修改当前视图, + 不要刷新。」 + ⚠️ 原来那张表单是**内联**在日历网格上方的卡片,一展开就把月视图推下去 —— + 正是"影响当前日历的显示"。改成浮层后,底下的日历**一动不动**。 + ⚠️ 视觉沿用站内既有的浮层语言(遮罩 rgba(44,35,29,.55) + 圆角面板 + 阴影), + 与选人弹窗 / 名册动作框 / 裁剪器是同一套,用户不用重新认。 + ============================================================ */ +.cal-modal { + position: fixed; inset: 0; z-index: 200; + display: flex; align-items: flex-start; justify-content: center; + padding: 24px 16px; overflow-y: auto; + background: rgba(44, 35, 29, .55); overscroll-behavior: contain; +} +.cal-modal[hidden] { display: none; } +.cal-modal-panel { + width: min(680px, 100%); margin: auto; + background: var(--surface); border-radius: 14px; + box-shadow: var(--shadow-l); display: flex; flex-direction: column; + max-height: calc(100vh - 48px); +} +.cal-modal-head { + display: flex; align-items: center; gap: 10px; + padding: 14px 16px 12px; border-bottom: 1px solid var(--line-2); + flex: 0 0 auto; +} +.cal-modal-head b { font-size: 15.5px; font-weight: 600; } +.cal-modal-x { + margin-left: auto; border: 0; background: transparent; cursor: pointer; + font-size: 15px; line-height: 1; color: var(--muted); + padding: 4px 7px; border-radius: 6px; +} +.cal-modal-x:hover { background: var(--hov-veil); color: var(--ink); } +.cal-modal-body { padding: 16px; overflow-y: auto; flex: 1 1 auto; } +.cal-modal-foot { + display: flex; align-items: center; gap: 10px; + padding: 12px 16px 14px; border-top: 1px solid var(--line-2); + flex: 0 0 auto; +} +/* 提交键贴右(次要动作在左、主要动作在右,与全站一致) */ +.cal-modal-foot .btn[type="submit"] { margin-left: auto; } +.cal-modal-tip { font-size: 12.5px; color: var(--muted); } +.cal-modal-tip.err { color: var(--red); } + +/* 日历本:色块 + 下拉(原生 option 画不出色块,见模板里的说明) */ +.cal-book-row { display: flex; align-items: center; gap: 8px; } +.cal-book-dot { + flex: 0 0 auto; width: 20px; height: 20px; border-radius: 6px; + border: 1px solid rgba(44, 35, 29, .12); +} +.cal-book-row .select { flex: 1 1 auto; } + +/* ============================================================ + v24 第 21 批 第 7 条:日程新建弹框的显隐(**必须放收尾**) + ------------------------------------------------------------ + ⚠️ 为什么单独补这一条:本项目有一条全局兜底 + `.field[hidden], .field.full[hidden], [data-mode-vehicle][hidden], … { display:none !important }` + (第 20 批为了修"车辆藏不住"加的)。而弹框的**面板里就有 `.field`** —— + 面板一旦被隐藏,CSS 的 `display:flex` 与那条 `!important` 会打架, + 加上 `hidden` 属性同时落在多个层级上,会出现"明明摘了 hidden 却还是不显示"。 + 这里用 `:not([hidden])` 把"打开态"显式钉死(后写者赢 + 特异性更高), + 两种状态各归各的规则、互不牵连。 + ============================================================ */ +.cal-modal:not([hidden]) { display: flex; } +.cal-modal[hidden] { display: none !important; } diff --git a/assets/js/app.js b/assets/js/app.js index 0b77998..5256f65 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -409,8 +409,16 @@ // 那样往"票根"里拖会跑到"海报"上(用户看到的是"拖到这儿,结果上到那儿")。 // 所以先找**同一个 .field 里**的那个,找不到才退回表单里的第一个。 var stageBox = null; - var ownField = input.closest ? input.closest('.field') : null; + var ownField = input.closest ? input.closest('.field') : ''; if (ownField) { stageBox = ownField.querySelector('[data-drop-stage]'); } + // v24 第 21 批 第 7 条:新结构里文件框是 **sr-only、且与框互为兄弟** + // (不再包在 .field 里 —— 片段自己画框,调用方只 require 一行)。 + // 所以再认一次"显式指向":框上写了 data-sd-for=""。 + if (!stageBox && input.id) { + stageBox = document.querySelector('[data-drop-stage][data-sd-for="' + input.id + '"]'); + } + // 最后才退回"表单里第一个"(老结构;多上传区的表单**必须**能就近命中, + // 否则往「票根」拖会跑到「海报」上 —— 第 20 批踩过,注释见上) if (!stageBox && input.form) { stageBox = input.form.querySelector('[data-drop-stage]'); } if (stageBox) { queueDrop(stageBox, input, files); @@ -445,7 +453,7 @@ 做法(三条链路,缺一不可): ① 拖进来 → **立刻** POST 到 upload_stage.php(`?r=upload/stage` 由路由转过去), 服务端落进暂存目录并回一个 `stoken`; - ② 把 stoken 塞进隐藏字段 ``—— + ② 把 stoken 塞进隐藏字段(名字由落点给,见 `data-stage-field`)—— 表单提交时这些令牌就跟着走了,业务路由在 attach_save_files 里**认领**; ③ 立刻画一张缩略图(指向 `media.php?stage=`), 让用户看得见"这一张已经上去了"。 @@ -463,8 +471,13 @@ function queueDrop(box, input, files) { var form = input.form; if (!form) { return; } - var list = box.querySelector('[data-stage-list]'); - var tip = box.querySelector('[data-stage-tip]'); + // ⚠️ v24 第 21 批 第 7 条:清单与提示行现在在框**外面**(同一个 `.upfield` 容器里)—— + // 清单放框内的话,加了五张图那个虚线框就变成"大框套着五张图"。 + // 所以先在容器里找,找不到才退回框自身(`.upfield` 不在时至少别静默失效: + // 没有清单 = 上传成功却什么都不显示,用户会以为没上去)。 + var host = (box.closest && box.closest('.upfield')) || box.parentNode || box; + var list = host.querySelector('[data-stage-list]') || box.querySelector('[data-stage-list]'); + var tip = host.querySelector('[data-stage-tip]') || box.querySelector('[data-stage-tip]'); // 队列状态挂在 box 上:**跨多次拖放**共享(用户拖一次、再拖一次,仍要串行) var st = box.__stage || (box.__stage = { pending: 0, chain: Promise.resolve() }); @@ -499,6 +512,9 @@ // 但整条清单是 display:none —— 用户看到的是"拖了没反应", // 而且缩略图也点不到(真机探针实测:卡片在 DOM 里、尺寸 0×0)。 list.hidden = false; + // 提示行同理(模板也给了 hidden)—— 它要说"已上传,可以继续拖"这类话, + // 藏着等于一个字都不说。 + if (tip) { tip.hidden = false; } } st.pending++; @@ -544,10 +560,16 @@ // 令牌进隐藏字段 → 随表单提交,服务端在 attach_save_files 里认领它 var hid = document.createElement('input'); hid.type = 'hidden'; - // ⚠️ 字段名由落点决定:附件类是 `stage[]`(服务端一次认领一批), - // 海报/票根是 `stage_poster` / `stage_ticket`(各自单槽位、要按比例重裁)。 - // 写死 `stage[]` 会让海报存成 1:1、页面上变形(见 _stage_drop.php 的说明)。 - hid.name = box.getAttribute('data-stage-field') || 'stage[]'; + // ⚠️ 字段名由落点决定,**不能写死**: + // · 通用附件是 `stage_token[]` —— 🔴 v24 第 21 批 第 4 条: + // 它原来叫 `stage[]`,与行程表单里那个 `stage`(plan/actual) + // 同名 → PHP 把 `$_POST['stage']` 合并成**数组** → + // 路由 `(string)` 强转成 "Array" → 静默退回 'plan' + // (症状:拖过图的"实际行程"一保存就变回计划)。 + // · 海报/票根是 `stage_poster` / `stage_ticket`(各自单槽位、要按比例重裁), + // 写死通用名会让海报存成 1:1、页面上变形。 + // 两条都有详细说明在 templates/_stage_drop.php 的头部。 + hid.name = box.getAttribute('data-stage-field') || 'stage_token[]'; hid.value = d.stoken; cell.appendChild(hid); // 撤回这张(令牌与卡片一起去掉;服务端的暂存由 TTL 清) @@ -607,15 +629,50 @@ return false; } - /** 一个 input:既给它自己挂落点,也给"指向它的那张预览图"挂(见 bindZone) */ + /** + * 一个 input:既给它自己挂落点,也给"指向它的那张预览图 / 那个上传框"挂(见 bindZone)。 + * + * ⚠️ v24 第 21 批 第 7 条:给「点击或拖动进行上传」那个框补**点击**。 + * 用户原话:「点击这个框实现点击上传按钮的功能」—— + * 框是
,它**不会**自己打开文件选择框(那是 /