diff --git a/assets/css/app.css b/assets/css/app.css index 69b9ca5..c4a5f77 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -5604,3 +5604,20 @@ h5.md-h, h6.md-h { font-size: 14px; } .an-md-sep { color: var(--muted); font-size: 13px; } /* 观影式行里的"填充块"(选人组件那种自带布局的控件,占满控件位) */ .mv-row > .mv-fill { flex: 1 1 auto; min-width: 0; } + +/* ---- v42(用户 2026-10-11 第 8 条)纪念日「日期」:AntD 单日期控件 ---- + 与原观影/高级搜索那两处同一套做法: + · `.dt-antd` 是 antd 的挂载点(宽度交给它); + · 挂上后(`.is-antd`)把**原生 `` 视觉收起** —— + 它仍是值载体(antd 通过 change 写它),只是不再显示给用户。 + ⚠️ 用 clip 收起(不是 display:none):保留可聚焦、可被表单读到, + 与 v40 高级搜索那处的取舍一致。 */ +.dt-input.is-antd > .dt-native { position: absolute; width: 1px; height: 1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; padding: 0; margin: -1px; } +.dt-input.is-antd > .dt-antd { display: block; width: 100%; } +.dt-input.dt-antd-single { display: block; width: 100%; } + +/* ---- v42(用户第 6 条)简介为空时的那句可点占位 ---- + 与 `.trip-sum` 同一视觉位置,只是颜色淡一档(它是要点进去补的提示,不是内容)。 */ +.trip-sum-empty { margin: 10px 0 0; font-size: 13px; color: var(--muted); } +.trip-sum-empty a { color: var(--brand); } diff --git a/assets/js/app.js b/assets/js/app.js index c05ed72..f290382 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1305,7 +1305,36 @@ ⚠️ 两条路共用下面同一套"按需加载 + 中文 locale"的 ensure(), 只是挂载函数不同 —— 别复制一份加载逻辑出来(那必然有一天两边不同步)。 */ var antdDateRanges = document.querySelectorAll('[data-antd-daterange]'); - if (antdHosts.length || antdDateRanges.length) { bootAntd(antdHosts, antdDateRanges); } + /* 🔴 v42(用户第 8 条):单个**纯日期**控件(纪念日的「日期」)—— + `[data-antd-date]`。它挂的是不带 showTime 的单 DatePicker。 */ + var antdDates = document.querySelectorAll('[data-antd-date]'); + if (antdHosts.length || antdDateRanges.length || antdDates.length) { + bootAntd(antdHosts, antdDateRanges, antdDates); + } + + /* 🔴🔴 v42(用户 2026-10-11 第 2 条)——**关键时序修复**: + 下面 `if (!hasMonth) { … return; }` 这条"没有 month 控件就提前退出"的短路 + (v37 为省 4 MB Semi 资源而加),会让**本 IIFE 尾部**的那几个 + `window.fbXxx = …` 赋值**永远不执行**。 + 而详情页的行程弹框正是靠 `window.fbBootAntd` / `window.fbUpgradeRange` + 把 AJAX 塞进来的那对起止控件升级成 AntD 的 —— 于是: + 「添加行程,开始和结束时间,并没有使用我们在观影模式的控件」(用户第 2 条)。 + ⚠️ 这个缺陷从 v40 引入 `fbBootAntd` 起就存在,只是当时**只有观影详情页**用, + 而那页首屏就带 `[data-dt-antd]`(不满足 `!hasMonth`…… 实为首屏有值、 + 直接走了另一条分支),所以一直没暴露;行程这条路径才把它踩出来。 + ⇒ 修法:**把对外接口的赋值提到所有 `return` 之前**(函数声明会提升, + 所以这里能安全引用下面定义的 `upgradeRange` / `bootAntd` / `writeDT`)。 + 尾部那几处保留(幂等),不改动其他调用点的语义。 */ + window.fbUpgradeRange = function () { + Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange); + return true; + }; + window.fbBootAntd = function () { + bootAntd(document.querySelectorAll('[data-dt-antd]'), + document.querySelectorAll('[data-antd-daterange]'), + document.querySelectorAll('[data-antd-date]')); + return true; + }; /* ⚠️ 只要还有 month 控件就得拉 Semi;纯 .dt-input 页面(绝大多数)不用拉。 */ if (!hasMonth) { @@ -1589,10 +1618,11 @@ ⚠️ 这个适配层**不认识的页面不受影响**:没有 `[data-dt-antd]` 就直接返回。 ============================================================================ */ - function bootAntd(hosts, dateRanges) { + function bootAntd(hosts, dateRanges, dates) { hosts = hosts || []; dateRanges = dateRanges || []; - if (!hosts.length && !dateRanges.length) { return; } + dates = dates || []; + if (!hosts.length && !dateRanges.length && !dates.length) { return; } var meta = document.querySelector('meta[name="fb-antd"]'); if (!meta) { return; } var conf; @@ -1634,6 +1664,9 @@ Array.prototype.forEach.call(dateRanges, function (el) { if (mountAntdDateRange(el)) { done++; } }); + Array.prototype.forEach.call(dates, function (el) { + if (mountAntdDate(el)) { done++; } + }); if (window.console && done) { console.info('AntD 日期控件已挂上 ' + done + ' 处'); } }).catch(function (err) { // 拉不动就留在自建下拉上 —— 渐进增强的兜底,不弹任何打扰 @@ -1707,6 +1740,55 @@ return true; } + /* 🔴 v42(用户 2026-10-11 第 8 条):「新建纪念日,月日那里…也用我们最新的时间控件, + 后面的时分就不需要了,只需要日期。」 + ⇒ 单个**纯日期**的 AntD DatePicker(不是 RangePicker)。 + 值载体是一个 ``(写成 `YYYY-MM-DD`), + 由调用方从它再拆出 month/day(纪念日的存储契约是 month + day)。 */ + function mountAntdDate(el) { + if (!el || el.getAttribute('data-antd-done')) { return false; } + var native = el.querySelector('input[type="date"]'); + if (!native) { return false; } + + var host = document.createElement('span'); + host.className = 'dt-antd dt-antd-dateonly'; + el.insertBefore(host, el.firstChild); + + function toDayjs(str) { + var m = (str || '').trim().match(/^(\d{4})-(\d{2})-(\d{2})$/); + return m ? window.dayjs(m[1] + '-' + m[2] + '-' + m[3], 'YYYY-MM-DD') : null; + } + var last = ''; + function writeBack(str) { + var s = str || ''; + if (s === last) { return; } + last = s; + native.value = s; + native.dispatchEvent(new Event('change', { bubbles: true })); + } + + var v0 = (native.value || '').trim(); + try { + var picker = React.createElement(antd.DatePicker, { + format: 'YYYY-MM-DD', + allowClear: true, + placeholder: '选择日期', + defaultValue: toDayjs(v0) || undefined, + onChange: function (val, str) { writeBack(str); } + }); + var loc = (window.antd.locales && window.antd.locales.zh_CN) || undefined; + if (loc) { picker = React.createElement(antd.ConfigProvider, { locale: loc }, picker); } + ReactDOM.createRoot(host).render(picker); + } catch (e) { + if (window.console) { console.warn('antd DatePicker 挂载失败,留在原生输入:', e && e.message); } + host.parentNode.removeChild(host); + return false; + } + el.classList.add('is-antd'); + el.setAttribute('data-antd-done', '1'); + return true; + } + /** 把 dayjs 切到简体中文(星期/月份/相对时间都由它出)。 * ⚠️ 拿不到就静默跳过 —— 那只会让星期显示成英文,不该拦住整个控件。 */ function applyDayjsZh() { @@ -1982,7 +2064,8 @@ */ window.fbBootAntd = function () { bootAntd(document.querySelectorAll('[data-dt-antd]'), - document.querySelectorAll('[data-antd-daterange]')); + document.querySelectorAll('[data-antd-daterange]'), + document.querySelectorAll('[data-antd-date]')); return true; }; diff --git a/carddav.php b/carddav.php index 5579a4a..6659c89 100644 --- a/carddav.php +++ b/carddav.php @@ -82,8 +82,54 @@ $path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/carddav/'), PH * 三种客户端一次都满足。 */ if ($path === '/.well-known/carddav') { + /* 🔴🔴 v42(用户第 10 条):与 dav.php 完全同一套修法 —— + 带鉴权的 PROPFIND 直接给发现内容(207),其余仍是 301 到**绝对 URL**。 + (Thunderbird 建账号时常带鉴权 PROPFIND 这个地址, + 只回 301 它不跟 ⇒ 「找不到通讯录」。) */ + $cdWantPropfind = (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'PROPFIND'); + $cdHasAuth = !empty($user) && $pass !== null && $pass !== ''; + if ($cdWantPropfind && $cdHasAuth) { + $cdWho = Auth::davAuth($user, $pass); + if (!$cdWho) { + header('WWW-Authenticate: Basic realm="FamilyBook DAV"'); + cd_fail(401, '需要 DAV 口令(在「个人设置 → DAV 同步」页生成,不是登录密码)。'); + } + $cdFamily = (int)$cdWho['family_id']; + $cdMe = (int)$cdWho['person_id']; + $cdParts = [ + '' . CardDav::x(CardDav::HOME_PATH) . '' + . '' + . '' . CardDav::x('家书通讯录') . '' + . '' . CardDav::x(CardDav::PRINCIPAL_PATH) + . '' + . '' . CardDav::x(CardDav::PRINCIPAL_PATH) + . '' + . '' . CardDav::x(CardDav::HOME_PATH) + . '' + . '', + ]; + foreach (CardDav::books($cdFamily, $cdMe) as $cdB) { + $cdParts[] = '' . CardDav::x(CardDav::hrefOf($cdB)) . '' + . '' + . '' + . '' . CardDav::x((string)$cdB['name']) . '' + . ''; + } + header('Content-Type: application/xml; charset=utf-8'); + http_response_code(207); + echo '' . "\n" + . '' + . implode('', $cdParts) . ''; + exit; + } http_response_code(301); - header('Location: /carddav/'); + $wkHost = (string)($_SERVER['HTTP_HOST'] ?? ''); + $wkProto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + if ($wkProto === 'http' && !empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) { + $wkProto = (string)$_SERVER['HTTP_X_FORWARDED_PROTO']; + } + header('Location: ' . (($wkHost !== '') ? ($wkProto . '://' . $wkHost . '/carddav/') : '/carddav/')); exit; } diff --git a/dav.php b/dav.php index db095ee..1e5275b 100644 --- a/dav.php +++ b/dav.php @@ -90,8 +90,79 @@ $path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/dav/'), PHP_UR * (本批实测踩过:跳到 principal 只对"严格按 RFC 5397 走的"客户端够用。) */ if ($path === '/.well-known/caldav') { + /* 🔴🔴 v42(用户第 10 条)核心修复之一: + 这条路径原来**只回 301**(跳 `/dav/`)。匿名 GET 时这是对的; + 但 Thunderbird 建账号时**常常是带鉴权直接 PROPFIND 这个地址** + (它把 `.well-known/caldav` 当成"DAV 服务的入口"来探,而不是只当跳转)。 + 对带鉴权的 PROPFIND 回 301 的后果:**TB 不跟这个跳转** + (它对"服务发现跳转"的跟随策略与浏览器不同),于是判定 + "这里没有 CalDAV 服务" ⇒ 报「找不到日历」。 + ⇒ 修法:**按方法分流** + · 有鉴权凭据 + PROPFIND → 直接给出与 `/dav/` 一致的发现响应(207, + 含 current-user-principal / calendar-home-set / 日历本清单), + 客户端**一步到位**,不依赖它对 301 的跟随; + · 其余(匿名、GET、OPTIONS…)→ 仍是 301 到绝对 URL `/dav/`(RFC 6764 语义)。 + ⚠️ 免鉴权这条必须保住:匿名 OPTIONS/GET 不能 401(否则客户端以为不支持 DAV)。 */ + $wkWantPropfind = (strtoupper($method) === 'PROPFIND'); + $wkHasAuth = !empty($user) && $pass !== null && $pass !== ''; + if ($wkWantPropfind && $wkHasAuth) { + $wkWho = Auth::davAuth($user, $pass); + if (!$wkWho) { + header('WWW-Authenticate: Basic realm="FamilyBook DAV"'); + dav_fail(401, '需要 DAV 口令(在「个人设置 → DAV 同步」页生成,不是登录密码)。'); + } + $wkFamily = (int)$wkWho['family_id']; + $wkMe = (int)$wkWho['person_id']; + $wkParts = [ + '/dav/' + . '' + . '' . CalDav::x('家书日历') . '' + . '' . CalDav::x(CalDav::PRINCIPAL_PATH) + . '' + . '' . CalDav::x(CalDav::PRINCIPAL_PATH) + . '' + . '' . CalDav::x(CalDav::HOME_PATH) + . '' + . '' . CalDav::x(CalDav::CARD_HOME_PATH) + . '' + . '', + ]; + foreach (CalDav::books($wkFamily, $wkMe) as $wkC) { + $wkParts[] = '' . CalDav::x(CalDav::hrefOf($wkC)) . '' + . '' + . '' + . '' . CalDav::x('家书 · ' . (string)$wkC['name']) . '' + . '' + . ''; + } + CalDav::multis(); + echo '' . "\n" + . '' + . implode('', $wkParts) . ''; + exit; + } http_response_code(301); - header('Location: /dav/'); + /* 🔴🔴 v42(用户 2026-10-11 第 10 条:「caldav 还是不行,thunderbird 提示找不到日历」) + 这里原来是**相对路径** `Location: /dav/`。 + ⚠️ RFC 7231 允许相对 Location(客户端按 RFC 3986 解析), + 但 Thunderbird 的 CalDAV 服务发现在这一跳上**对相对 Location 不可靠** —— + `curl -L` 会跟、浏览器会跟,但 TB 自己不跟, + 它把这个 301 当成"这里没有 CalDAV 服务" ⇒ 报「找不到日历」。 + 而我们的探针用 curl/urllib(都会自动跟)⇒ 全绿, + 于是"探针全过、真客户端不行"这个组合一直复现不出来。 + ⇒ 改成**绝对 URL**(RFC 6764 §6.1 的示例也是绝对 URL)。 + ⚠️ 按**真实请求的 host**拼(不能写死域名): + 本站既可能被 family.example.com 访问,也可能被 IP / 其它别名访问, + 写死会把客户端带到别的域(跨域后鉴权头会丢)。 */ + $wkHost = (string)($_SERVER['HTTP_HOST'] ?? ''); + $wkProto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + // 走代理(宝塔 nginx→apache)时协议可能丢,再从 X-Forwarded-Proto 补一次 + if ($wkProto === 'http' && !empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) { + $wkProto = (string)$_SERVER['HTTP_X_FORWARDED_PROTO']; + } + $wkTarget = ($wkHost !== '') ? ($wkProto . '://' . $wkHost . '/dav/') : '/dav/'; + header('Location: ' . $wkTarget); exit; } diff --git a/templates/days/form.php b/templates/days/form.php index 0485d9b..1f293e9 100644 --- a/templates/days/form.php +++ b/templates/days/form.php @@ -64,6 +64,12 @@ $backUrl = url('days'); if ($anModal) { echo ' id="anNewCard" role="dialog" aria-modal="true" aria-label="' . ($editing ? '编辑纪念日' : '新建纪念日') . '"' . ($anOpen ? '' : ' hidden'); + /* 🔴 v42 第 9 条:标记"这是**整页直达**的弹框"(不是列表页内嵌的), + 以及它该退回到哪里 —— 关闭时若无宿主容器,就跳回列表页。 + `$anModalOnly` = "被列表页内嵌",那种情形由列表页的容器接管,不用跳页。 */ + if (!$anModalOnly) { + echo ' data-an-standalone="1" data-an-back="' . e($backUrl) . '"'; + } } ?>>
@@ -108,15 +114,24 @@ $backUrl = url('days');
+ ` 作值载体(antd 写它), + JS 把它拆成两个 hidden 的 `month` / `day`(见下方脚本)。 + 这样 bind 到 antd 的 onChange → 写 an_date → 监听 change → 拆 month/day, + 整条链只有一个"真值来源",不会出现两处不同步。 + ⚠️ `name="an_date"` 不是 `month/day` 本身 —— 它只是中间载体, + 提交前脚本必把它拆好;服务端不读 an_date(days/save 读 month/day)。 */ ?>
- 月 / 日* -
- - 月 - - 日 + 日期* +
+ + +
@@ -175,4 +190,78 @@ $backUrl = url('days');
+ + + + \ No newline at end of file diff --git a/templates/resources/index.php b/templates/resources/index.php index d22b492..d067d58 100644 --- a/templates/resources/index.php +++ b/templates/resources/index.php @@ -111,9 +111,13 @@ $myPerson = (int)\App\Auth::personId();
- - - + +
@@ -175,6 +179,22 @@ $myPerson = (int)\App\Auth::personId();
+ + +
@@ -523,8 +543,140 @@ $myPerson = (int)\App\Auth::personId(); } } if (addBtn && addCard) { - addBtn.addEventListener('click', function () { setResOpen(true); }); + addBtn.addEventListener('click', function () { + // 🔴 「新建」前先复位成"新建态"(上一次点过「编辑」的话表单已被灌过值) + resetToCreate(); + setResOpen(true); + }); } + + /* ============================================================ + 🔴 v42(用户 2026-10-11 第 7 条):「资源编辑的弹框,复用新建资源的弹框, + 保持一致。」—— 列表里那颗「编辑」按钮现在也开**同一个** `#resAddCard`: + · 把行上的 data-*(标题/正文/归属人)灌进表单; + · `id` 隐藏框填上这条的 id,表单 action 切成 `resources/update`; + · 标题栏文字换成「编辑资源」。 + 原来的旧页签面板(`#resEditPanel`,v28 那套)**不再是编辑入口** —— + 它现在只承担"附件 / 共享"两个页签(那两块仍是它独有的能力)。 + ⚠️ 分享的预填:那一行 button 上带了 `data-res-shares`(JSON), + 这里把它写进选人组件的隐藏字段;选人组件自己的 chip 由 fbPersonPicker 重画。 + ============================================================ */ + var CREATE_ACTION = ; + var UPDATE_ACTION = ; + + function setPickerValues(name, ids) { + if (!addCard) { return; } + /* 🔴 用全局选人组件自己的接口(`window.ppSetSelected`,见 person_picker.js)—— + 它内部会:建候选清单(懒建)→ 设 checked → 重画 chips。 + 自己手改隐藏字段是不够的:外面的 chip 排不会跟着变,用户看到"没带上"。 + ⚠️ 第三个参数把**本弹框**作为 root 传进去:页面上可能有多个选人组件, + 不限定 root 会命中文档里**第一个**(这正是 v24 踩过的坑,见它的注释)。 */ + if (typeof window.ppSetSelected === 'function') { + window.ppSetSelected(name, ids.map(String), addCard); + return; + } + // 兜底:组件没加载时至少把隐藏值写上(提交是对的,只是 chip 显示不出来) + var pp = addCard.querySelector('.pp[data-pp-name="' + name + '"]'); + if (!pp) { return; } + Array.prototype.forEach.call(pp.querySelectorAll('input[type=hidden][name="' + name + '"]'), + function (i) { i.parentNode.removeChild(i); }); + ids.forEach(function (id) { + var i = document.createElement('input'); + i.type = 'hidden'; i.name = name; i.value = String(id); + pp.appendChild(i); + }); + } + + function openEdit(btn) { + if (!addCard) { return; } + var id = btn.getAttribute('data-res-id') || ''; + var form = document.getElementById('resForm'); + var idEl = document.getElementById('rs-id'); + if (idEl) { idEl.value = id; } + if (form) { form.action = UPDATE_ACTION; } + var ttl = document.getElementById('resAddTitle'); + if (ttl) { ttl.textContent = '编辑资源'; } + var t = document.getElementById('rs-title'); + if (t) { t.value = btn.getAttribute('data-res-title') || ''; } + var c = document.getElementById('rs-content'); + if (c) { c.value = btn.getAttribute('data-res-content') || ''; } + // 归属人(单选) + var owner = btn.getAttribute('data-res-owner') || ''; + if (owner) { setPickerValues('owner_id', [parseInt(owner, 10)]); } + // 共享(多选) + var sh = btn.getAttribute('data-res-shares') || '[]'; + var ids = []; + try { + var arr = JSON.parse(sh); + ids = arr.map(function (o) { return o.id; }); + } catch (e) { ids = []; } + setPickerValues('share_ids[]', ids); + setResOpen(true); + /* 🔴 v42:编辑态才显示底栏的「共享」「附件」两颗按钮(新建时资源还不存在, + 没有"共享给谁/挂哪些附件"可说)。 */ + var bs = document.getElementById('resToShare'); + var ba = document.getElementById('resToAtt'); + if (bs) { bs.hidden = false; bs.setAttribute('data-res-id', id); } + if (ba) { ba.hidden = false; ba.setAttribute('data-res-id', id); } + } + + /* 底栏的「共享」「附件」:**复用产品原有的入口** —— + 它们合成一个 `[data-res-open]` 事件交给页签面板那段脚本处理 + (那段会把 id 填进**每一个** `form[data-res-idform]`,见它的长注释)。 + ⚠️ 必须走产品那条路(而不是自己再写一遍"打开面板 + 灌 id"): + 否则反向验证里"把 id 注入改回只填第一个"这个注入**打不到探针** ⇒ + 证伪失效(本轮实测就栽在这里 —— 探针绕过产品逻辑,等于没测)。 + ⚠️ 用 `click()` 派发到页签面板脚本会命中的元素上:页签面板脚本监听 + `document` 上的 click 并读 `closest('[data-res-open]')`, + 所以这里造一个临时的 `[data-res-open]` 元素来 click 最直接可靠。 */ + function openPane(pane, id) { + var t = document.createElement('button'); + t.type = 'button'; + t.setAttribute('data-res-open', pane); + t.setAttribute('data-res-id', id); + t.style.display = 'none'; + document.body.appendChild(t); + t.click(); + t.parentNode.removeChild(t); + } + ['resToShare', 'resToAtt'].forEach(function (bid) { + var el = document.getElementById(bid); + if (!el) { return; } + el.addEventListener('click', function () { + var pane = el.getAttribute('data-res-pane') || ''; + var id = el.getAttribute('data-res-id') || ''; + if (!pane || !id) { return; } + setResOpen(false); // 先收起新弹框(避免两层遮罩叠着) + openPane(pane, id); + }); + }); + + function resetToCreate() { + var form = document.getElementById('resForm'); + var idEl = document.getElementById('rs-id'); + if (idEl) { idEl.value = ''; } + if (form) { form.action = CREATE_ACTION; } + var ttl = document.getElementById('resAddTitle'); + if (ttl) { ttl.textContent = '新建资源'; } + var t = document.getElementById('rs-title'); if (t) { t.value = ''; } + var c = document.getElementById('rs-content'); if (c) { c.value = ''; } + /* 🔴 v42:回到"新建态"时收起底栏那两颗编辑专用按钮(见 openEdit 的注释) */ + ['resToShare', 'resToAtt'].forEach(function (bid) { + var el = document.getElementById(bid); + if (el) { el.hidden = true; el.removeAttribute('data-res-id'); } + }); + setPickerValues('share_ids[]', []); + } + + /* 事件委托:列表里的「编辑」按钮(AJAX 换过列表后仍然有效) */ + document.addEventListener('click', function (e) { + var t = e.target; + if (!t || !t.closest) { return; } + var b = t.closest('[data-res-open="edit"]'); + if (!b) { return; } + e.preventDefault(); + openEdit(b); + }); ['resAddX', 'resAddCancel'].forEach(function (id) { var el = document.getElementById(id); if (el) { el.addEventListener('click', function () { setResOpen(false); }); } @@ -692,6 +844,13 @@ $myPerson = (int)\App\Auth::personId(); e.stopPropagation(); var which = op.getAttribute('data-res-open'); + /* 🔴 v42(用户第 7 条):**「编辑」不再走这个旧页签面板** —— + 它现在复用「新建资源」那个弹框(`#resAddCard`,见本文件前一个 script)。 + 那颗按钮的处理在那边(也在 document 上委托,先于本段执行)。 + 这里只管 `att` / `share` / `del` 三个动作的页签面板。 + ⚠️ 必须在这里让开:否则同一颗按钮会被两处都接住, + 表现为"编辑弹框开了、旧的页签面板也弹出来"(两层遮罩叠在一起)。 */ + if (which === 'edit') { return; } var id = op.getAttribute('data-res-id'); var title = op.getAttribute('data-res-title') || ''; var panel = panels[which]; diff --git a/templates/trips/_list_body.php b/templates/trips/_list_body.php index 8378a7f..6513f70 100644 --- a/templates/trips/_list_body.php +++ b/templates/trips/_list_body.php @@ -25,31 +25,39 @@
diff --git a/templates/trips/form.php b/templates/trips/form.php index 15cec44..80f0700 100644 --- a/templates/trips/form.php +++ b/templates/trips/form.php @@ -28,8 +28,20 @@ * 否则用户提交失败后跳到这一页只会看到一张空白(提示全在弹框里)。 * * @param array|null $t 编辑时的旅程;null = 新建 + * @param array|null $tIn 【列表页内嵌时用】同上语义 * @param array $errors 校验错误 + * + * 🔴🔴 v42(用户 2026-10-11 第 1 条):「新建旅程,把数据带进来了。」 + * 真根因:`trips/index.php` 用 `$tIn`(=null)把"编辑数据"传进来, + * 而这份模板读的是 `$t` —— 偏偏 `trips/_list_body.php` 里有个 + * `foreach ($rows as $t)`,**循环结束后 `$t` 残留为列表里最后一个旅程**! + * ⇒ 新建弹框被填上了那趟旅程的标题/简介/封面。 + * ⇒ 修法(两道保险,且不破坏路由那条路径): + * ① 契约兼容:列表页显式传了 `$tIn` 时**以它为准**(那份才是"要编辑的"); + * 路由(trips/new、trips/edit)走的仍是 `$t`,原样不动; + * ② 列表页那边把 `_list_body.php` 的循环变量改名,**不再残留 `$t`**。 */ +if (isset($tIn)) { $t = $tIn; unset($tIn); } $isEdit = !empty($t); $canWrite = \App\Auth::canWrite(); $cvSlot = \App\ImageSlot::get('cover') ?: ['ratio' => '16:9', 'w' => 1600, 'h' => 900, diff --git a/templates/trips/node_form.php b/templates/trips/node_form.php index 5d8ee23..2e770b9 100644 --- a/templates/trips/node_form.php +++ b/templates/trips/node_form.php @@ -196,7 +196,7 @@ $nfEmbed = !empty($nfEmbed); ⚠️ 隐藏字段仍是 `*_date/_hour/_minute`,提交契约一字未改; antd 拉不动时自动退回自建下拉(渐进增强)。 */ ?>
- + @@ -339,7 +339,7 @@ $nfEmbed = !empty($nfEmbed); 那种做法一旦父容器有 transform/filter,定位元素会改锚点、按钮就飘了。) ⚠️ 每个缩略图的 `photo_id` 不同 → 每张一个独立的删除表单(id 带 $ph['id'])。*/ ?>
- +
@@ -503,9 +503,24 @@ $nfEmbed = !empty($nfEmbed); (function () { 'use strict'; - function bindLoc(prefix) { - var input = document.getElementById('loc-' + prefix + '-input'); + /* 🔴🔴 v42(用户 2026-10-11 第 3/4 条): + 这两段(高德地址联想 / 出行方式联动车辆与车次)原来是"页面加载时跑一次"的。 + 但 v41 把行程表单改成了**详情页弹框 + AJAX 取片段** —— 片段塞进 DOM 时 + **内联 \ No newline at end of file diff --git a/templates/trips/show.php b/templates/trips/show.php index 75f38a8..1ec60f0 100644 --- a/templates/trips/show.php +++ b/templates/trips/show.php @@ -169,6 +169,23 @@ $groupMeta = [ `_tooling/_fb_b19_backfill.php` 一次性搬进 summary。 */ ?>

+ + +

+ 还没有简介 —— + 点这里写一句。 +

` **既不执行、连元素都不保留** + (`innerHTML` 的语义:脚本不会跑,且赋值后 DOM 里也找不到那个 script 标签)。 + 片段自带的初始化(AntD 升级 / 高德联想 / 出行方式联动)全写在那段脚本里 + ⇒ 用户在弹框里看到的是: + · 起止时间没换成 AntD 控件(第 2 条); + · 选「自驾」不出车辆、选「火车/飞机」不出车次(第 3 条); + · 出发地/到达地没有高德联想(第 4 条)。 + ⇒ 做法:**把片段里的内联 `