v41 第 41 批:流水线提速 + 12 项界面/交互开发 + DAV 彻底修复
DAV(用户「务必彻底解决」): - 真根因是 home-set 层级错:CalDav::HOME_PATH 原为 /dav/calendars/、 CardDav 原为 /carddav/addressbooks/,而日历本/通讯录的 href 在 /dav/N.ics/ 与 /carddav/N.vcf/ —— home-set 下面一本都没有。 Thunderbird 按 home-set 前缀认成员,于是「找不到日历本 / 找不到联系人」。 改为 /dav/ 与 /carddav/(所有既有 URL 一字不变,老配置不作废)。 - 又发现并修掉 Depth:0 违反 RFC 4918:原实现无论 depth 都列出全部子集合, 且第一条 response 是日历本(不含 current-user-principal)。改为按 depth 分流, Depth:0 只回资源自身、Depth:1 才列出子集合。 界面与交互: - 观影:新建影院弹窗层级修正 + 详细地址;照片左右按钮悬停不再位移; 弹框底栏改「确定 / 取消」。 - 旅程:新建旅程改弹窗并按观影式排版;详情页四个动作按钮按用户指定顺序与配色; 编辑与新增行程弹窗化(单容器 + AJAX);行程时间控件改 AntD(精确到分); 参与人默认带上自己;车次号/车牌号紧跟交通方式词。 - 资源:新建/编辑改弹窗 + 观影式排版 + 新建时即定共享人。 - 纪念日:新建改弹框 + 观影式排版。 验证: - e2e 1510/0;v41 十二项真机 48/0;DAV 反向验证 8/0; 部署断言空跑 337/0;模板 div 深度三形态穷举 33 组全配平。 - 线上 DAV 终验(--resolve 真实入口 + 真 DAV 口令)12/0; 线上确认 Depth:0 恰好 1 个 response。
This commit is contained in:
+28
-2
@@ -117,12 +117,38 @@ final class CalDav
|
||||
*/
|
||||
public const PRINCIPAL_PATH = '/dav/principal/';
|
||||
|
||||
public const HOME_PATH = '/dav/calendars/';
|
||||
/**
|
||||
* 🔴🔴 v41(用户 2026-10-10 第 11 条:「caldav 和 carddav 功能还是不好用,
|
||||
* thunderbird 返回的结果是,找不到联系人,找不到日历本,再仔细检查这个功能,
|
||||
* 这次要务必彻底解决。」)
|
||||
*
|
||||
* 真根因(线上实测出来的,不是猜的):
|
||||
* 原来 `HOME_PATH = '/dav/calendars/'`,而**日历本自己的 href 是 `/dav/1.ics/`**
|
||||
* —— 也就是说 **日历本并不在 home-set 之下**。
|
||||
* Thunderbird(严格按 RFC 4791/6352):拿到 `calendar-home-set` 后,
|
||||
* PROPFIND 它、**只看它的成员**当作日历本 ⇒ 那里一个都没有
|
||||
* ⇒ 报「找不到日历本」。CardDAV 侧一模一样(`/carddav/addressbooks/`
|
||||
* vs 通讯录 `/carddav/1.vcf/`)⇒ 「找不到联系人」。
|
||||
*
|
||||
* ⚠️ 为什么 v33 那批没抓出来:那批的探针**只验证"发现链每一步都回 207"**,
|
||||
* 而每一步确实都回了 207、principal 与 home-set 也都拿到了 ——
|
||||
* **层级关系错了这件事,单个请求的返回码看不出来**。
|
||||
* ⇒ 新探针必须断言"**home-set 的成员里必须有日历本/通讯录**"。
|
||||
*
|
||||
* 修法:让 home-set 就指向**本协议自己的根**(`/dav/`、`/carddav/`)——
|
||||
* ① 成员关系立刻成立(`/dav/1.ics/` 确实在 `/dav/` 之下);
|
||||
* ② **所有既有 URL 一字不变**(客户端里配的 `/dav/`、`/dav/1.ics/` 继续有效,
|
||||
* 不需要用户重新配置,也不会让已同步的客户端全部失效);
|
||||
* ③ 少一层跳转。
|
||||
* ⚠️ 别改成"把日历本挪到 /dav/calendars/ 下面"——那会让**每一个用户**的
|
||||
* 客户端配置作废(已订阅的日历会 404),代价大得多。
|
||||
*/
|
||||
public const HOME_PATH = '/dav/';
|
||||
|
||||
/** 通讯录那边同义(见 App\CardDav,两边刻意保持一致) */
|
||||
public const CARD_PRINCIPAL_PATH = '/carddav/principal/';
|
||||
|
||||
public const CARD_HOME_PATH = '/carddav/addressbooks/';
|
||||
public const CARD_HOME_PATH = '/carddav/';
|
||||
|
||||
// ---------------- 应答 ----------------
|
||||
|
||||
|
||||
+9
-1
@@ -52,7 +52,15 @@ final class CardDav
|
||||
*/
|
||||
public const PRINCIPAL_PATH = '/carddav/principal/';
|
||||
|
||||
public const HOME_PATH = '/carddav/addressbooks/';
|
||||
/**
|
||||
* 🔴🔴 v41(用户 2026-10-10 第 11 条:「thunderbird 返回的结果是,找不到联系人,
|
||||
* 找不到日历本」)——真根因与 CalDAV 那边**一模一样**(见 CalDav::HOME_PATH 的长注释):
|
||||
* 原来是 `/carddav/addressbooks/`,而通讯录自己的 href 是 `/carddav/1.vcf/`
|
||||
* ⇒ **通讯录不在 home-set 之下** ⇒ Thunderbird 在 home-set 里一本都找不到
|
||||
* ⇒ 报「找不到联系人」。
|
||||
* 修法:home-set 就指向 `/carddav/` 自己(成员关系成立,且所有既有 URL 不变)。
|
||||
*/
|
||||
public const HOME_PATH = '/carddav/';
|
||||
|
||||
/**
|
||||
* 当前用户可见的"通讯录"。
|
||||
|
||||
+40
-2
@@ -110,6 +110,43 @@ final class MovieStats
|
||||
return self::WEEKDAY_COLORS[$w] ?? '#A84A28';
|
||||
}
|
||||
|
||||
/**
|
||||
* 年份配色(v41 用户 2026-10-10):「按年统计,也用不同的颜色来显示。」
|
||||
*
|
||||
* 为什么不按"年份 % 12"去借 MONTH_COLORS:那样 **12 年一循环**,
|
||||
* 一个 30 年的观影史会出现两遍同色,看着像"同一年"。
|
||||
* ⇒ 这里按**年份序号**在色相环上均匀取点(黄金角步进),
|
||||
* 相邻年份色相差得开、且**不随年份范围变化**(同一个年份永远是同一个颜色,
|
||||
* 不会因为"库里多了几年"就换色 —— 那种不稳定比不好看更糟)。
|
||||
*/
|
||||
public static function yearColor(int $year): string
|
||||
{
|
||||
// 黄金角 ≈ 137.5°,让相邻年份在色相环上尽量分开。
|
||||
// 用绝对年份参与运算 ⇒ 结果只取决于年份本身(稳定、可复现)。
|
||||
$hue = fmod(($year * 137.508), 360.0);
|
||||
if ($hue < 0) {
|
||||
$hue += 360.0;
|
||||
}
|
||||
// 明度/饱和度固定在这一档:宣纸底上都能看清,也不与品牌暖棕打架。
|
||||
return self::hslToHex($hue, 0.46, 0.47);
|
||||
}
|
||||
|
||||
/** HSL → #rrggbb(只给 yearColor 用,避免引一整个颜色库) */
|
||||
private static function hslToHex(float $h, float $s, float $l): string
|
||||
{
|
||||
$c = (1 - abs(2 * $l - 1)) * $s;
|
||||
$x = $c * (1 - abs(fmod($h / 60.0, 2) - 1));
|
||||
$m = $l - $c / 2;
|
||||
if ($h < 60) { $r = $c; $g = $x; $b = 0.0; }
|
||||
elseif ($h < 120) { $r = $x; $g = $c; $b = 0.0; }
|
||||
elseif ($h < 180) { $r = 0.0; $g = $c; $b = $x; }
|
||||
elseif ($h < 240) { $r = 0.0; $g = $x; $b = $c; }
|
||||
elseif ($h < 300) { $r = $x; $g = 0.0; $b = $c; }
|
||||
else { $r = $c; $g = 0.0; $b = $x; }
|
||||
return sprintf('#%02x%02x%02x',
|
||||
(int)round(($r + $m) * 255), (int)round(($g + $m) * 255), (int)round(($b + $m) * 255));
|
||||
}
|
||||
|
||||
/** 按观影人:参与了多少场(带头像,供统计页画条;名称按「我叫他什么」显示)
|
||||
*
|
||||
* 🔴 v40(用户 2026-10-10):「观影统计里面,如果这个人在名册里面,但是他一部电影的
|
||||
@@ -125,13 +162,14 @@ final class MovieStats
|
||||
public static function byPerson(int $familyId, int $viewerId = 0): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT p.id, p.name, p.avatar, n.nickname AS my_nickname, COUNT(e.id) AS cnt
|
||||
"SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname,
|
||||
COUNT(e.id) AS cnt
|
||||
FROM persons p
|
||||
LEFT JOIN event_attendees a ON a.person_id = p.id
|
||||
LEFT JOIN events e ON e.id = a.event_id AND e.kind = 'movie' AND e.family_id = ?
|
||||
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
|
||||
WHERE p.family_id = ? AND p.kind = 'member'
|
||||
GROUP BY p.id, p.name, p.avatar, n.nickname
|
||||
GROUP BY p.id, p.name, p.avatar, p.avatar_color, n.nickname
|
||||
HAVING cnt > 0
|
||||
ORDER BY cnt DESC, p.id"
|
||||
);
|
||||
|
||||
@@ -658,6 +658,31 @@ final class Trip
|
||||
$st->execute([$eventId]);
|
||||
$rows = $st->fetchAll();
|
||||
$ids = array_map('intval', array_column($rows, 'id'));
|
||||
/* 🔴 v41(用户 2026-10-10 第 8 条):行程行要把「自驾的车牌号」直接跟在
|
||||
「自驾」两个字后面 ⇒ 这里一次性把用到的车辆**车牌**挂上去。
|
||||
⚠️ 用一条 IN 查询而不是逐行 JOIN:一张"十几段"的行程单逐行查会把
|
||||
N 次往返变成 N 次;车辆是低频引用(多数段没有车),
|
||||
IN 一次就够。
|
||||
⚠️ 字段名 `vehicle_plate` 与 `Vehicle::label` 的取向不同:
|
||||
label 是给"选车下拉"用的(车牌 + 名称,方便辨认),
|
||||
详情页要的是**车牌号本身**(用户原话就是"车牌号")。 */
|
||||
$vehIds = array_values(array_filter(array_map(
|
||||
static fn($n) => (int)($n['vehicle_id'] ?? 0), $rows)));
|
||||
if ($vehIds) {
|
||||
$vin = implode(',', array_fill(0, count(array_unique($vehIds)), '?'));
|
||||
$sv = Database::get()->prepare(
|
||||
"SELECT id, plate FROM vehicles WHERE id IN ($vin)"
|
||||
);
|
||||
$sv->execute(array_values(array_unique($vehIds)));
|
||||
$plates = [];
|
||||
foreach ($sv->fetchAll() as $v) {
|
||||
$plates[(int)$v['id']] = (string)$v['plate'];
|
||||
}
|
||||
foreach ($rows as &$nv) {
|
||||
$nv['vehicle_plate'] = $plates[(int)($nv['vehicle_id'] ?? 0)] ?? '';
|
||||
}
|
||||
unset($nv);
|
||||
}
|
||||
// 一次性把全部节点的附件取出来(逐节点查会让"十几段的旅程"变成几十次查询)
|
||||
$attach = [];
|
||||
foreach ($ids as $nid) {
|
||||
|
||||
+180
-2
@@ -20,6 +20,15 @@
|
||||
蓝=编辑、红=删除,全站统一,跟着模块换肤也不该变。
|
||||
⚠️ 暖纸底上要够暗才压得住白字:这里取 #2F6FB0(对比度 ≈ 4.9:1)。 */
|
||||
--blue: #2F6FB0;
|
||||
/* 🔴 v41(用户 2026-10-10):旅程详情页四种动作按钮各一种底色
|
||||
(计划=蓝 / 实际=绿 / 编辑=黄 / 删除=红)。蓝、绿(--green)、红已有;
|
||||
这里补"编辑黄"。
|
||||
⚠️ 纯黄(#FFD400 之类)配白字几乎看不清(对比度 ≈ 1.3:1)。
|
||||
取偏琥珀的深黄 #B8860B(DarkGoldenrod 系),暖纸底上白字对比度 ≈ 3.6:1,
|
||||
且不会和品牌暖棕 (`--brand` #A84A28) 混淆 —— 后者明显更红。
|
||||
⚠️ "实际=绿"用既有的 `--green` (#6F9E5C),它在白字下对比度 ≈ 3.0:1,
|
||||
深一步更保险 —— 见 `.btn-actual` 里单独取的 #4E7C3F(≈ 4.6:1)。 */
|
||||
--yellow: #B8860B;
|
||||
--line: rgba(44, 35, 29, .10);
|
||||
--line-2: rgba(44, 35, 29, .06);
|
||||
--radius: 16px;
|
||||
@@ -1710,7 +1719,8 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
||||
position: absolute; border: none; cursor: pointer; color: #fff;
|
||||
background: rgba(255, 255, 255, .12); display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
transition: background-color .2s ease; font-family: inherit;
|
||||
transition: background-color .2s ease, filter .2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
.lb-btn:hover { } /* 深底上不能用薄雾:走文末统一段的 brightness 提亮 */
|
||||
.lb-btn[hidden] { display: none; }
|
||||
@@ -1723,6 +1733,17 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
||||
}
|
||||
.lb-dl:hover { background: #eee; }
|
||||
.lb-nav { top: 50%; transform: translateY(-50%); width: 48px; height: 48px; border-radius: 50%; font-size: 28px; padding-bottom: 4px; }
|
||||
/* 🔴🔴 v41(用户 2026-10-10):「观影照片的左右按钮,鼠标悬停之后位置会变一下,
|
||||
一不小心就会点错。」
|
||||
真根因:文末「全站悬停统一」段给 `.lb-btn:hover` 加了
|
||||
`transform: translateY(var(--hov-lift))`(= 上移 1px 的"轻抬升"手感)——
|
||||
而 `.lb-nav` 是靠 `transform: translateY(-50%)` 做**垂直居中**的,
|
||||
hover 那条**把居中的 transform 整个覆盖掉** ⇒ 鼠标一碰,按钮从"居中"跳到
|
||||
"距顶 50%",**位移接近半个按钮高度**(48px 的一半),确实会点错。
|
||||
⚠️ 与 v21 家谱那次 `.f3 div.card:hover > div` 覆盖库的居中 transform 是**同一个坑**。
|
||||
⇒ 修:`.lb-nav:hover` 显式把它自己的居中 transform 写回去(只保留提亮,
|
||||
不再抬升)。位置在 hover 前后**一字不动**,鼠标停在哪就点在哪。
|
||||
⚠️ 必须写在统一 hover 段**之后**(同特异性下后者胜)—— 见文末那段里的同名规则。 */
|
||||
.lb-prev { left: 14px; }
|
||||
.lb-next { right: 14px; }
|
||||
.lb-bar {
|
||||
@@ -2942,7 +2963,14 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
||||
|
||||
/* 新建影院:居中弹框(与名册的动作弹框同一形态,只是更宽) */
|
||||
.cn-modal {
|
||||
position: fixed; inset: 0; z-index: 70;
|
||||
/* 🔴🔴 v41(用户 2026-10-10):「观影模块,第一场电影,新建影院这个弹窗不显示,
|
||||
被记一场电影这个弹窗盖住了。」
|
||||
真根因:`.cn-modal` 是 **70**,而「记一场电影」用的 `.cal-modal` 是 **200** ——
|
||||
影院弹框是**从观影弹框里**打开的子层,层级却更低 ⇒ 被整块盖住、看不见也点不到。
|
||||
⇒ 提到 260:必须**高于 .cal-modal(200)**,也高于 antd 的下拉面板
|
||||
(`.ant-picker-dropdown` 走 popper,默认 1050 之外的那一层我们没碰;
|
||||
这里只需保证"压过所有自建弹框"即可)。 */
|
||||
position: fixed; inset: 0; z-index: 260;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.cn-modal[hidden] { display: none; }
|
||||
@@ -3784,6 +3812,12 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
||||
filter: brightness(1.18);
|
||||
transform: translateY(var(--hov-lift));
|
||||
}
|
||||
/* 🔴🔴 v41 例外(必须写在上面那条**之后**,靠"同特异性、后者胜"生效):
|
||||
`.lb-nav`(照片左右切换)是**靠 transform 居中**的,一旦被上面的
|
||||
`translateY(var(--hov-lift))` 覆盖,hover 时按钮会从"居中"跳到"距顶 50%",
|
||||
位移近半个按钮高 —— 用户报的就是这个("悬停之后位置会变一下,一不小心就会点错")。
|
||||
⇒ 这里把它的居中 transform 显式写回,只保留提亮。位置前后一字不动。 */
|
||||
.lb-nav:hover { transform: translateY(-50%); }
|
||||
|
||||
/* v24 第 20 批 第 3 条:`form="id"` 关联用的**表单外壳**。
|
||||
它们只承载字段、不出现在视觉里(按钮在原来的位置上,用 form= 指过来)。
|
||||
@@ -5426,3 +5460,147 @@ h5.md-h, h6.md-h { font-size: 14px; }
|
||||
/* 纯日期版控件与观影那边同款外观(同高、同圆角、同描边)—— 复用 .dt-antd 的规则,
|
||||
这里只补"它是块级、占满字段宽度"这一点(.dt-antd 原为 flex 子项)。 */
|
||||
.dt-antd-dateonly { display: block; width: 100%; }
|
||||
|
||||
/* ============================================================================
|
||||
🔴 v41(用户 2026-10-10)旅程详情页的四个动作按钮:计划=蓝 / 实际=绿 / 编辑=黄 / 删除=红
|
||||
============================================================================
|
||||
为什么四种都要在**页头动作区那一层**再写一遍:
|
||||
页头有一套"特异性更高"的规则(`.page-head .actions > .btn:not(.btn-icon)` 等,
|
||||
见文件前部的页头按钮层),它们会盖过基类的 `.btn-xxx` 底色。
|
||||
v40 的 `.btn-del` 就吃过这一脚(必须在页头层再补一条才生效)。
|
||||
⚠️ 三处形态都要写(裸类 / a. / button.):`.btn`、`a.btn`、`button.btn`
|
||||
的特异性不同,漏一种就有一种是错的(这条规律在 v40 已经验证过)。
|
||||
⚠️ 设置按钮**不动**(用户:"设置就用这个图标,不要变")。 */
|
||||
.btn-plan, a.btn-plan, button.btn-plan {
|
||||
background: var(--blue); color: #fff; border-color: transparent;
|
||||
}
|
||||
.btn-actual, a.btn-actual, button.btn-actual {
|
||||
/* 绿比 --green 深一档:白字对比度 3.0 → 4.6,暖纸底上看得清 */
|
||||
background: #4E7C3F; color: #fff; border-color: transparent;
|
||||
}
|
||||
/* 🔴🔴 2026-10-11 修(v40 套件报的**真回归**):
|
||||
这里原来写的是**裸类** `.btn-edit`(全局改黄),直接把 v40 影院管理页的
|
||||
「编辑 = 蓝色实心」覆盖掉了 —— 影院那页量出来的实际色是 `rgb(184,134,11)`(黄),
|
||||
而用户对它的要求是**蓝色**(v40 原话:「编辑使用蓝色的背景,删除使用红色的背景」)。
|
||||
⇒ 两处需求不同:**旅程详情页的「编辑」= 黄**(v41 第 4 条),
|
||||
**影院管理页的「编辑」= 蓝**(v40)。所以颜色必须**按作用域**给,不能裸类全局设。
|
||||
锚点选 `[data-trip-edit]`(旅程详情页那个按钮的专属属性)——
|
||||
它是最精确的标识,不依赖页头层级,也不会波及影院管理页的 `<a class="btn btn-edit">`。 */
|
||||
.btn-edit[data-trip-edit] {
|
||||
background: var(--yellow); color: #fff; border-color: transparent;
|
||||
}
|
||||
.btn-del, a.btn-del, button.btn-del {
|
||||
background: var(--red); color: #fff; border-color: transparent;
|
||||
}
|
||||
/* hover:同色系加深(与文末"全站悬停统一"段同一手法:brightness 压低) */
|
||||
.btn-plan:hover, .btn-actual:hover, .btn-edit:hover, .btn-del:hover { filter: brightness(.92); }
|
||||
/* 页头动作区那一层(覆盖页头默认的"品牌色实心/ghost") */
|
||||
.page-head .actions > .btn-plan:not(.btn-icon),
|
||||
.page-head .actions > .btn-actual:not(.btn-icon),
|
||||
.page-head .actions > .btn-edit[data-trip-edit]:not(.btn-icon),
|
||||
.page-head .actions > .btn-del:not(.btn-icon),
|
||||
.page-head .actions > form > button.btn-del:not(.btn-icon) { color: #fff; border-color: transparent; }
|
||||
.page-head .actions > .btn-plan:not(.btn-icon) { background: var(--blue); }
|
||||
.page-head .actions > .btn-actual:not(.btn-icon) { background: #4E7C3F; }
|
||||
.page-head .actions > .btn-edit[data-trip-edit]:not(.btn-icon) { background: var(--yellow); }
|
||||
.page-head .actions > .btn-del:not(.btn-icon),
|
||||
.page-head .actions > form > button.btn-del:not(.btn-icon) { background: var(--red); }
|
||||
|
||||
/* ---- v41 第 8 条:交通方式的"标识"(车次号 / 车牌号)紧跟模式词 ----
|
||||
用户:「把火车的车次号直接放在"火车"这两个字后面。如果是自驾,
|
||||
就应该把自驾的车牌号放在后面。」
|
||||
⇒ 同一个 chip 内、模式词之后,用略淡的字重区分(仍属同一个信息单元)。 */
|
||||
.node-mode-no {
|
||||
margin-left: 6px; padding-left: 6px;
|
||||
border-left: 1px solid currentColor;
|
||||
opacity: .78; font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- v41 第 2 条:题图的说明文字搬进框里(原来在框外另起一段)---- */
|
||||
.cover-picker-hint {
|
||||
display: block; margin-top: 8px;
|
||||
font-size: 12.5px; line-height: 1.6; color: var(--muted);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
🔴 v41(用户 2026-10-10 第 5/6 条)行程弹框的**观影式排版**
|
||||
============================================================================
|
||||
用户原话:「编辑行程、加计划行程、加实际行程,全部都采用弹窗的形式。
|
||||
其排版的方式和添加电影是一样的,前面是标题冒号,后面是输入框,
|
||||
和观影模块一样,注意排版。」
|
||||
|
||||
🔴🔴 为什么这里用 **grid** 而不是 flex(2026-10-10 血泪):
|
||||
先按观影那套写了 flex(`display:flex; flex-wrap:wrap` + label 固定 62px),
|
||||
结果**控件被挤到下一行**(实测 label y=117 / 控件 y=187)。
|
||||
查了半天:`.input/.select` 全站有 `width: 100%`,在 flex 行里
|
||||
它的 auto 基准就是**整行宽** ⇒ 62 + 10 + 696 > 696 ⇒ 换行。
|
||||
把 width 改 auto、把 flex-basis 从 0% 试到 auto、加 min-width……
|
||||
控件宽度是对了(140),**换行依旧**(同一行里还有 `.tip` 声明着
|
||||
`flex: 0 0 100%`,wrap 算法会把它们的理想宽度一起算)。
|
||||
⇒ 换 **grid**:`.field` 两列(62px + 1fr),标签在第 1 列、控件在第 2 列,
|
||||
`.tip/.err` 用 `grid-column: 2` 自动落在控件下面**且与控件左缘对齐**
|
||||
(连 margin-left 都不需要)。两列表单的标准写法,不受 width/flex 交互影响。
|
||||
|
||||
⚠️ 作用域用 `.nf-form`(表单自己的类)而不是 `.nf-wrap` / `.nf-panel`:
|
||||
片段形态(`?embed=1`)两者都不输出(壳由详情页容器给),
|
||||
只用它们的话排版在弹框里**完全不生效**(探针抓到过)。 */
|
||||
.nf-form .form-grid {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.nf-form .form-grid > .field {
|
||||
display: grid; grid-template-columns: 62px minmax(0, 1fr);
|
||||
column-gap: 10px; row-gap: 6px; align-items: start;
|
||||
}
|
||||
/* 标签:固定第 1 列、右对齐、自动冒号(与观影那套同一组数字) */
|
||||
.nf-form .form-grid > .field > label {
|
||||
grid-column: 1;
|
||||
text-align: right; padding-top: 9px;
|
||||
font-size: 14px; font-weight: 600; color: var(--ink);
|
||||
line-height: 1.4; word-break: keep-all;
|
||||
}
|
||||
.nf-form .form-grid > .field > label::after {
|
||||
content: ':'; font-weight: 400; color: var(--muted);
|
||||
}
|
||||
/* 控件:占第 2 列(宽度由 grid 给,不受它的 width:100% 影响) */
|
||||
.nf-form .form-grid > .field > *:not(label) {
|
||||
grid-column: 2; min-width: 0;
|
||||
}
|
||||
/* 提示 / 错误:也在第 2 列 ⇒ **自动与控件左缘对齐** */
|
||||
.nf-form .form-grid > .field > .tip,
|
||||
.nf-form .form-grid > .field > .err {
|
||||
grid-column: 2;
|
||||
}
|
||||
/* 起终点是两列小网格,整段落在第 2 列 */
|
||||
.nf-form .form-grid > .field > .loc-row { grid-column: 2; }
|
||||
/* 「当前状态」是只读文本,对齐 40px 高的控件 */
|
||||
.nf-form .node-stage-ro { padding-top: 9px; }
|
||||
/* 窄屏:标签列压到 52px,避免控件被挤得太窄 */
|
||||
@media (max-width: 560px) {
|
||||
.nf-form .form-grid > .field { grid-template-columns: 52px minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
/* ---- v41 第 8 条:交通方式的"标识"(车次号 / 车牌号)紧跟模式词 ----
|
||||
用户:「把火车的车次号直接放在"火车"这两个字后面。如果是自驾,
|
||||
就应该把自驾的车牌号放在后面。」
|
||||
⇒ 同一个 chip 内、模式词之后,用略淡的字重区分(仍属同一个信息单元)。 */
|
||||
.node-mode-no {
|
||||
margin-left: 6px; padding-left: 6px;
|
||||
border-left: 1px solid currentColor;
|
||||
opacity: .78; font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- v41 第 2 条:题图的说明文字搬进框里(原来在框外另起一段)---- */
|
||||
.cover-picker-hint {
|
||||
display: block; margin-top: 8px;
|
||||
font-size: 12.5px; line-height: 1.6; color: var(--muted);
|
||||
}
|
||||
|
||||
|
||||
/* ---- v41 第 10 条:纪念日弹框(观影式排版)里"月 / 日"那一行的两格 ---- */
|
||||
.an-md { display: flex; align-items: center; gap: 6px; }
|
||||
.an-md .input { width: 88px; flex: 0 0 auto; text-align: center; }
|
||||
.an-md-sep { color: var(--muted); font-size: 13px; }
|
||||
/* 观影式行里的"填充块"(选人组件那种自带布局的控件,占满控件位) */
|
||||
.mv-row > .mv-fill { flex: 1 1 auto; min-width: 0; }
|
||||
|
||||
+23
@@ -195,6 +195,29 @@ if ($method === 'PROPFIND') {
|
||||
// 5c. home-set(以及 `/carddav/` 本身):列出这个人可见的通讯录。
|
||||
// ⚠️ `/carddav/` 也走这条路:用户与老客户端都直接把 /carddav/ 当 home 用。
|
||||
if ($isHome || $bookId === 0) {
|
||||
// 🔴🔴 2026-10-11:与 dav.php 同一个协议隐患 —— **Depth:0 只应回资源自身**。
|
||||
// 原来无论 depth 都把全部通讯录一起回,线上实测 `PROPFIND /carddav/ Depth:0`
|
||||
// 第一条 response 是通讯录(不含 current-user-principal),
|
||||
// 严格客户端可能判"拿不到 principal"。
|
||||
// ⇒ Depth:0 只回 `/carddav/` 自身(principal / home-set 都在里面)。
|
||||
if ($depth !== '1') {
|
||||
$emit([
|
||||
'<d:response><d:href>' . CardDav::x(CardDav::HOME_PATH) . '</d:href>'
|
||||
. '<d:propstat><d:prop>'
|
||||
. '<d:resourcetype><d:collection/></d:resourcetype>'
|
||||
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
|
||||
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
|
||||
. '</d:href></d:current-user-principal>'
|
||||
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
|
||||
. '</d:href></d:principal-URL>'
|
||||
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
|
||||
. '</d:href></card:addressbook-home-set>'
|
||||
. $readOnlyPriv
|
||||
. '</d:prop></d:propstat></d:response>',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
// 🔴 v33:**始终**先输出 home 集合自身。
|
||||
// RFC 4918 规定:PROPFIND 的响应**必须包含被请求的资源本身**,
|
||||
|
||||
@@ -233,6 +233,39 @@ if ($method === 'PROPFIND') {
|
||||
if ($isHome || $bookId === 0) {
|
||||
$baseHref = $isHome ? CalDav::HOME_PATH : '/dav/';
|
||||
$parts = [];
|
||||
|
||||
// 🔴🔴 2026-10-11(本批线上实测发现的协议隐患,修之):
|
||||
// RFC 4918 规定 **Depth:0 只描述「请求的那个资源本身」**,
|
||||
// 而这里原来无论 depth 都把**所有日历本**一起回。
|
||||
// 线上实测的坏结果:`PROPFIND /dav/ Depth:0` 的**第一条 response
|
||||
// 是 `/dav/1.ics/`**(日历本自己,**不含 current-user-principal**)
|
||||
// —— 严格客户端(Thunderbird / iOS)只读"与请求 URL 匹配的那条"或
|
||||
// 第一条,就可能判"拿不到 principal"而中止。
|
||||
// ⇒ 按 depth 分流:
|
||||
// · Depth:0 → **只回 `/dav/` 自身**(principal、home-set 都在这一条里,
|
||||
// 所以"把 /dav/ 当入口的老客户端"仍然一次就能往下走);
|
||||
// · Depth:1 → 自身 + 各日历本。
|
||||
if ($depth !== '1') {
|
||||
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
|
||||
. 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [
|
||||
'<d:response><d:href>' . CalDav::x($baseHref) . '</d:href>'
|
||||
. '<d:propstat><d:prop>'
|
||||
. '<d:resourcetype><d:collection/></d:resourcetype>'
|
||||
. '<d:displayname>' . CalDav::x('家书日历') . '</d:displayname>'
|
||||
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
|
||||
. '</d:href></d:current-user-principal>'
|
||||
. '<d:principal-URL><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
|
||||
. '</d:href></d:principal-URL>'
|
||||
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
|
||||
. '</d:href></c:calendar-home-set>'
|
||||
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
|
||||
. '</d:href></card:addressbook-home-set>'
|
||||
. $readOnlyPriv
|
||||
. '</d:prop></d:propstat></d:response>',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
foreach (CalDav::books($familyId, $me) as $c) {
|
||||
$href = CalDav::hrefOf($c);
|
||||
$parts[] = '<d:response><d:href>' . CalDav::x($href) . '</d:href>'
|
||||
|
||||
@@ -3394,6 +3394,10 @@ switch ($r) {
|
||||
'presetAttendees' => [(int)Auth::personId()],
|
||||
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
|
||||
'errors' => [],
|
||||
/* 🔴 v41:直达 `trips/new` 时也渲染成**打开着的弹框**。
|
||||
不打开的话,用户看到的是一张"空白页面"(内容全在 hidden 的弹框里)。 */
|
||||
'tpModal' => true,
|
||||
'tpOpen' => true,
|
||||
], 'trips');
|
||||
break;
|
||||
|
||||
@@ -3405,12 +3409,24 @@ switch ($r) {
|
||||
flash('err', '找不到这个旅程。');
|
||||
redirect('trips');
|
||||
}
|
||||
View::render('trips/form', [
|
||||
'title' => '编辑旅程',
|
||||
/* 🔴 v41:详情页的「编辑」按钮也改弹框(用户第 4 条把那排按钮列全了,
|
||||
第 5/6 条要求弹窗化)。`?embed=1` → 只吐表单片段,由详情页弹框容器承载。 */
|
||||
$tpEmbed = (string)($_GET['embed'] ?? '') === '1';
|
||||
$tpArgs = [
|
||||
't' => $t,
|
||||
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
|
||||
'errors' => [],
|
||||
], 'trips');
|
||||
'tpEmbed' => $tpEmbed,
|
||||
'tpModal' => !$tpEmbed,
|
||||
'tpOpen' => true,
|
||||
'tpReload' => 'tripListHost',
|
||||
];
|
||||
if ($tpEmbed) {
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo View::renderPartial('trips/form', $tpArgs);
|
||||
exit;
|
||||
}
|
||||
View::render('trips/form', ['title' => '编辑旅程'] + $tpArgs, 'trips');
|
||||
break;
|
||||
|
||||
case 'trips/create':
|
||||
@@ -3479,11 +3495,33 @@ switch ($r) {
|
||||
}
|
||||
if ($errors) {
|
||||
set_old($_POST);
|
||||
/* 🔴 v41(用户 2026-10-10):新建旅程改弹框后,**校验失败**要
|
||||
把这同一个弹框重画回去(而不是整页跳走)。
|
||||
判据用 `X-Requested-With`(与观影详情页编辑同一套约定),
|
||||
回 200 + JSON `{ok:false, modal:'<片段>'}`。 */
|
||||
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'modal' => View::renderPartial('trips/form', [
|
||||
't' => $isEdit ? array_merge($t, $d) : null,
|
||||
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
|
||||
'errors' => $errors,
|
||||
'tpModal' => true,
|
||||
'tpModalOnly' => false, // 要壳
|
||||
'tpOpen' => true, // 强制成"弹框 + 打开"
|
||||
]),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
View::render('trips/form', [
|
||||
'title' => $isEdit ? '编辑旅程' : '新建旅程',
|
||||
't' => $isEdit ? array_merge($t, $d) : null,
|
||||
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
|
||||
'errors' => $errors,
|
||||
// ⚠️ 整页直达时也要**打开弹框**:否则用户提交失败后跳到这一页
|
||||
// 只会看到一张空白(所有提示都在弹框里)。
|
||||
'tpModal' => true, 'tpOpen' => true,
|
||||
], 'trips');
|
||||
exit;
|
||||
}
|
||||
@@ -3504,6 +3542,12 @@ switch ($r) {
|
||||
);
|
||||
clear_old();
|
||||
flash('ok', '这个旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
||||
/* 🔴 v41(用户第 2 条):「确定完了之后,**动态刷新旅程这个列表页面**。」
|
||||
⇒ 从列表页弹框来的(`back=trips`)回**列表页**,让前端只换列表容器;
|
||||
其余入口(如直达整页 `trips/new`)保持老行为:进详情页加行程。 */
|
||||
if ((string)($_POST['back'] ?? '') === 'trips') {
|
||||
redirect('trips');
|
||||
}
|
||||
redirect('trips/show', ['id' => $newId]);
|
||||
break;
|
||||
|
||||
@@ -3568,8 +3612,13 @@ switch ($r) {
|
||||
$stage = 'plan'; // "未成行"是标记出来的,不是新建出来的
|
||||
}
|
||||
}
|
||||
View::render('trips/node_form', [
|
||||
'title' => ($isEdit ? '编辑行程' : '添加行程') . ' · ' . (string)$t['title'],
|
||||
/* 🔴 v41(用户 2026-10-10 第 5/6 条):新增/编辑行程改为**弹框**。
|
||||
`?embed=1`(详情页弹框里 fetch 时带的)→ 只吐**表单片段**(不带整页外壳),
|
||||
由详情页的弹框容器塞进去。
|
||||
⚠️ 不带 embed 时仍渲染整页 —— 老链接(书签、旧入口)照样能用,
|
||||
而且校验失败时整页重画也走这条路。 */
|
||||
$embed = (string)($_GET['embed'] ?? '') === '1';
|
||||
$ndArgs = [
|
||||
'ev' => $t,
|
||||
'n' => $nd,
|
||||
'stage' => $stage,
|
||||
@@ -3578,7 +3627,23 @@ switch ($r) {
|
||||
'vehicles' => Vehicle::pickable($familyId),
|
||||
'expenses' => $nd ? Expense::ofNode((int)$nd['id']) : [],
|
||||
'errors' => [],
|
||||
], 'trips');
|
||||
/* 🔴 v41:两种形态由**路由明确指定**,模板不猜:
|
||||
· embed=1(详情页弹框 fetch 片段)→ 无页头、无弹框壳(只吐表单);
|
||||
· 直达整页(含校验失败重画)→ 有页头 + 弹框壳且**打开**。 */
|
||||
'nfEmbed' => $embed,
|
||||
'nfModal' => !$embed,
|
||||
'nfShell' => !$embed,
|
||||
'nfOpen' => true,
|
||||
'nfReload' => 'nodeListHost',
|
||||
];
|
||||
if ($embed) {
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo View::renderPartial('trips/node_form', $ndArgs);
|
||||
exit;
|
||||
}
|
||||
View::render('trips/node_form',
|
||||
['title' => ($isEdit ? '编辑行程' : '添加行程') . ' · ' . (string)$t['title']] + $ndArgs,
|
||||
'trips');
|
||||
break;
|
||||
|
||||
// ---------------- 花销(行程级,v24 第 16 批 第 2 条)----------------
|
||||
@@ -3841,6 +3906,32 @@ switch ($r) {
|
||||
'to_city' => trim((string)($_POST['to_city'] ?? '')),
|
||||
];
|
||||
if ($d['title'] === '') {
|
||||
/* 🔴 v41(用户 2026-10-10 第 5/6 条):行程新增/编辑改成**弹框**后,
|
||||
校验失败不能在弹框里"整页跳走"(用户会看到白页面、也不知道错在哪)。
|
||||
⇒ AJAX 请求(弹框提交)回 200 + JSON `{ok:false, modal:'<重画片段>'}`,
|
||||
由详情页把这一段塞回弹框;非 AJAX(老式整页提交)保持原行为。 */
|
||||
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => '这段行程得有个名字。',
|
||||
'modal' => View::renderPartial('trips/node_form', [
|
||||
'ev' => $t,
|
||||
'n' => $isEdit ? Trip::node($nid, (int)Auth::personId()) : null,
|
||||
'stage' => (string)($_POST['stage'] ?? 'plan'),
|
||||
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
|
||||
'types' => NodeType::all($familyId),
|
||||
'vehicles' => Vehicle::pickable($familyId),
|
||||
'expenses' => $isEdit ? Expense::ofNode($nid) : [],
|
||||
'errors' => ['title' => '这段行程得有个名字。'],
|
||||
'nfEmbed' => true, // 只吐表单片段(壳由详情页容器给)
|
||||
'nfModal' => false,
|
||||
'nfShell' => false,
|
||||
'nfReload' => 'nodeListHost',
|
||||
]),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('err', '这段行程得有个名字。');
|
||||
redirect('trips/show', ['id' => $tripId]);
|
||||
}
|
||||
@@ -4291,6 +4382,11 @@ $want = array_keys($want);
|
||||
'editing' => $editing,
|
||||
'kinds' => Anniversary::kinds($familyId),
|
||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||
/* 🔴 v41(用户第 10 条):「新建纪念日,采用弹框形式。」
|
||||
直达 `days/new` 时也渲染成**打开着的弹框** ——
|
||||
否则用户看到的是一张空白页(内容全在 hidden 的弹框里)。 */
|
||||
'anModal' => true,
|
||||
'anOpen' => true,
|
||||
], 'days');
|
||||
break;
|
||||
|
||||
@@ -5214,6 +5310,21 @@ $want = array_keys($want);
|
||||
flash('ok', '资源已新建。');
|
||||
}
|
||||
$warns = attach_save_files($familyId, Resource::ATTACH, $rid, 'attach', $me);
|
||||
/* 🔴 v41(用户 2026-10-10 第 9 条):「添加资源,采用弹窗形式,并在新建资源的
|
||||
的时候,就确定共享人。」⇒ 表单里多了一个"共享给谁"的选人(`share_ids[]`),
|
||||
在这里一次性落库(新建与编辑都收)。
|
||||
⚠️ 权限一律给 `read`:新建时最多只到"只读共享"。
|
||||
要给人**可写**,去列表里那条资源的「共享」弹框单独设
|
||||
(那里能选只读/可读写)。这样"新建时勾错"的代价最小。
|
||||
⚠️ owner 自己会被 `setShare` 自动跳过(见 Resource::setShare 的第一行),
|
||||
所以"把自己也勾上"不会产生一条多余的共享记录。 */
|
||||
$shareIds = filter_family_person_ids((array)($_POST['share_ids'] ?? []), $familyId);
|
||||
foreach ($shareIds as $sid) {
|
||||
Resource::setShare($rid, $ownerId, (int)$sid, 'read');
|
||||
}
|
||||
if ($shareIds) {
|
||||
Notification::syncShares($familyId, Notification::RESOURCE, $rid, $me);
|
||||
}
|
||||
if ($warns) {
|
||||
flash('err', '有附件没有保存:' . implode(';', $warns));
|
||||
}
|
||||
|
||||
+104
-47
@@ -1,91 +1,128 @@
|
||||
<?php
|
||||
/**
|
||||
* 新建 / 编辑纪念日(v24 用户第 13 条)
|
||||
* 新建 / 编辑纪念日(v24 用户第 13 条;**v41 改成弹框 + 观影式排版**)
|
||||
*
|
||||
* 用户原话:「可以根据阴历和阳历来设置纪念日 …… 纪念日,需要按照阴历和阳历进行重复。」
|
||||
* 所以重复方式这一栏是核心:阳历 / 农历 / 只此一次。
|
||||
* 选农历时,下面的月/日填的是**农历**的月日(不是什么公历日期换算)。
|
||||
* 用户原话:
|
||||
* · 第 13 条:「可以根据阴历和阳历来设置纪念日 …… 纪念日,需要按照阴历和阳历进行重复。」
|
||||
* · v41 第 10 条:「新建纪念日,采用弹框形式,排版参考观影模块。」
|
||||
*
|
||||
* ⇒ 两条落实:
|
||||
* ① **弹框**(`$anModal`):从列表页内嵌,初始关闭;直达 `days/new` 时以打开状态渲染;
|
||||
* ② **排版**:整表单改用观影那套 `.mv-row`(左标签 62px + 右对齐 + 自动冒号,
|
||||
* 控件占满剩余宽度),不再用 `.field` 堆叠。
|
||||
*
|
||||
* ⚠️ 重复方式这一栏是核心:阳历 / 农历 / 只此一次。选农历时下面的月/日
|
||||
* 填的是**农历**的月日(不是公历换算)。
|
||||
*
|
||||
* @param array|null $editing 编辑时的既有记录
|
||||
* @param array $people 名册
|
||||
*/
|
||||
/* 🔴 v41:被列表页 `require` 内嵌时必须**自己兜住变量**。
|
||||
否则 `$editing` 未定义 → 每一处都吐 `Warning: Undefined variable`
|
||||
且**打进 HTML 正文**(`HTTP 200`,看着"页面正常",实际满屏警告,
|
||||
还把 `class="cal-modal"` 那行**切断**了 —— 实测:弹框容器因此变成
|
||||
`<div class="cal-modal"<br /><b>Warning</b>…` ⇒ 前端 `getElementById` 拿不到,
|
||||
"点新建没反应"。这正是全站那条"模板片段不许偷宿主变量"的同一类坑。 */
|
||||
$editing = $editing ?? null;
|
||||
$people = $people ?? [];
|
||||
$kinds = $kinds ?? null;
|
||||
|
||||
$e0 = $editing ?: [];
|
||||
$v = static fn(string $k, $def = '') => $e0[$k] ?? $def;
|
||||
$repeat = (string)($e0['repeat_type'] ?? 'solar');
|
||||
// 用户 2026-10-03 第 9 条:分类下拉的选项来自**可配置**的分类清单
|
||||
// (原来写死 PRESET_KINDS,用户要能自己增删 —— 见「纪念日设置」)。
|
||||
$kinds = (array)($kinds ?? \App\Anniversary::PRESET_KINDS);
|
||||
$curKind = (string)($e0['kind'] ?? '');
|
||||
// 编辑一条用了**已被删掉的分类**的纪念日时,要把它自己也列进下拉,
|
||||
// 否则保存一次就把它悄悄改成了第一个选项(用户没动它,值却变了)。
|
||||
if ($curKind !== '' && !in_array($curKind, $kinds, true)) {
|
||||
array_unshift($kinds, $curKind);
|
||||
}
|
||||
|
||||
/* 🔴 v41 弹框开关(与观影/旅程同一套命名习惯):
|
||||
$anModal —— 渲染成弹框壳
|
||||
$anModalOnly —— 被列表页内嵌(初始关闭)
|
||||
$anOpen —— 初始打开(直达 / AJAX 校验失败重画)
|
||||
$anEmbed —— 只吐片段(详情页/列表页弹框 fetch 用) */
|
||||
$anModal = !isset($anModal) ? true : (bool)$anModal;
|
||||
$anModalOnly = !empty($anModalOnly);
|
||||
$anOpen = !empty($anOpen);
|
||||
$anEmbed = !empty($anEmbed);
|
||||
$anShell = !isset($anShell) ? !$anEmbed : (bool)$anShell;
|
||||
|
||||
$backUrl = url('days');
|
||||
?>
|
||||
<?php if (!$anEmbed && !$anModal): ?>
|
||||
<div class="page-head">
|
||||
<h1><?= $editing ? '编辑纪念日' : '新建纪念日' ?></h1>
|
||||
<div class="actions">
|
||||
<a class="btn btn-ghost" href="<?= e(url('days')) ?>">返回</a>
|
||||
<a class="btn btn-ghost" href="<?= e($backUrl) ?>">返回</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form class="card card-pad" method="post" action="<?= e(url('days/save')) ?>">
|
||||
<?php if ($anShell): ?>
|
||||
<div class="<?= $anModal ? 'cal-modal' : 'card card-pad' ?>"<?php
|
||||
if ($anModal) {
|
||||
echo ' id="anNewCard" role="dialog" aria-modal="true" aria-label="'
|
||||
. ($editing ? '编辑纪念日' : '新建纪念日') . '"' . ($anOpen ? '' : ' hidden');
|
||||
} ?>>
|
||||
<?php if ($anModal): ?>
|
||||
<div class="cal-modal-panel mv-panel">
|
||||
<div class="cal-modal-head">
|
||||
<b><?= $editing ? '编辑纪念日' : '新建纪念日' ?></b>
|
||||
<button type="button" class="cal-modal-x" id="anNewX" aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="<?= e(url('days/save')) ?>" id="anForm">
|
||||
<?= csrf_field() ?>
|
||||
<?php if ($editing): ?><input type="hidden" name="id" value="<?= (int)$e0['id'] ?>"><?php endif; ?>
|
||||
<?php if ($anModal && !$editing): ?>
|
||||
<input type="hidden" name="back" value="days">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field full">
|
||||
<label for="an-title">叫什么 *</label>
|
||||
<div class="mv-form-inner">
|
||||
<div class="mv-grid">
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">叫什么<i>*</i></span>
|
||||
<input class="input" id="an-title" name="title" required
|
||||
value="<?= e((string)$v('title')) ?>" placeholder="如:结婚纪念日">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="an-repeat">按什么重复 *</label>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">按什么重复<i>*</i></span>
|
||||
<select class="select" id="an-repeat" name="repeat_type">
|
||||
<?php foreach (\App\Anniversary::REPEAT as $rk => $rv): ?>
|
||||
<option value="<?= e($rk) ?>" <?= $repeat === $rk ? 'selected' : '' ?>><?= e($rv) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<span class="tip">选「农历」时,下面填的是**农历**的月日(不是公历)。</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="an-kind">分类</label>
|
||||
<?php /* 用 <select> 而不是 <input list=…>:分类现在是**用户自己维护的一套**,
|
||||
下拉能保证选到的都是存在的分类;datalist 允许自由填,
|
||||
填出来的新词不会进清单,下次别人也不知道有这个词。 */ ?>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">分类</span>
|
||||
<select class="select" id="an-kind" name="kind">
|
||||
<option value="">不分类</option>
|
||||
<?php foreach ($kinds as $k): ?>
|
||||
<option value="<?= e((string)$k) ?>" <?= $curKind === (string)$k ? 'selected' : '' ?>><?= e((string)$k) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<span class="tip">分类可以在「纪念日」页右侧的设置里增删。</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="an-month">月 *</label>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">月 / 日<i>*</i></span>
|
||||
<div class="an-md">
|
||||
<input class="input" id="an-month" name="month" type="number" min="1" max="12" required
|
||||
value="<?= (int)$v('month', 1) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="an-day">日 *</label>
|
||||
value="<?= (int)$v('month', 1) ?>" aria-label="月">
|
||||
<span class="an-md-sep">月</span>
|
||||
<input class="input" id="an-day" name="day" type="number" min="1" max="31" required
|
||||
value="<?= (int)$v('day', 1) ?>">
|
||||
value="<?= (int)$v('day', 1) ?>" aria-label="日">
|
||||
<span class="an-md-sep">日</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>与谁相关</label>
|
||||
<?php /* 用户第 2 条:「每个纪念日现在只能关联一个人,应该可以关联多个人。
|
||||
和选择观影人一样,多选模式。」
|
||||
→ 换 `_person_picker.php` 的 **multi** 模式(与观影选人同一套:
|
||||
弹窗 + 拼音首字母联想 + 暂存区 + 确定)。
|
||||
⚠️ 字段名从 `person_id`(单值)改成 **`person_ids[]`**(多值)——
|
||||
`[]` 不是可有可无的记号:不写它,PHP 只会收到最后一个值,
|
||||
选了 3 个人只存上 1 个,而且**不报任何错**。
|
||||
⚠️ 已选的人:优先用新表的 person_ids;老记录回落到 person_id
|
||||
(升级过渡期的兜底,见 Anniversary::split)。 */ ?>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">与谁相关</span>
|
||||
<div class="mv-fill">
|
||||
<?php
|
||||
$ppName = 'person_ids[]';
|
||||
$ppPeople = $people;
|
||||
@@ -99,23 +136,43 @@ if ($curKind !== '' && !in_array($curKind, $kinds, true)) {
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="an-leap">农历闰月</label>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">农历闰月</span>
|
||||
<label class="chk-inline">
|
||||
<input type="checkbox" id="an-leap" name="is_leap" value="1" <?= !empty($e0['is_leap']) ? 'checked' : '' ?>>
|
||||
<span>这个农历月是闰月</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
<label for="an-note">备注</label>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">备注</span>
|
||||
<textarea class="input" id="an-note" name="note" rows="2"><?= e((string)$v('note')) ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit"><?= $editing ? '保存' : '添加' ?></button>
|
||||
<a class="btn btn-ghost" href="<?= e(url('days')) ?>">取消</a>
|
||||
</div>
|
||||
|
||||
<?php if ($anShell && $anModal): ?>
|
||||
<div class="cal-modal-foot">
|
||||
<span class="cal-modal-tip" id="anNewTip"></span>
|
||||
<button class="btn btn-sm" type="submit" form="anForm" id="anNewSubmit">确定</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" id="anNewCancel">取消</button>
|
||||
</div>
|
||||
</div><?php /* /.cal-modal-panel */ ?>
|
||||
<?php elseif ($anEmbed): ?>
|
||||
<div class="cal-modal-foot">
|
||||
<span class="cal-modal-tip" id="anNewTip"></span>
|
||||
<button class="btn btn-sm" type="submit" form="anForm" id="anNewSubmit">确定</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" data-nf-close>取消</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit" id="anNewSubmit"><?= $editing ? '确定' : '添加' ?></button>
|
||||
<a class="btn btn-ghost" href="<?= e($backUrl) ?>">取消</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<?php if ($anShell): ?>
|
||||
</div><?php /* 关掉开头那个外层(.cal-modal 或 .card) */ ?>
|
||||
<?php endif; ?>
|
||||
+116
-1
@@ -122,7 +122,12 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
|
||||
<span class="sub">生日本身就是一种纪念日 · 阳历与农历都能记</span>
|
||||
<div class="actions">
|
||||
<?php if ($canWrite): ?>
|
||||
<a class="btn" href="<?= e(url('days/new')) ?>">新建纪念日</a>
|
||||
<?php /* 🔴 v41(用户 2026-10-10 第 10 条):「新建纪念日,采用弹框形式,
|
||||
排版参考观影模块。」⇒ 改成按钮 + 弹框(原来是跳 `days/new` 页)。
|
||||
⚠️ 保留 data-href:JS 没跑时它仍是可直接访问的链接。 */ ?>
|
||||
<button class="btn" type="button" id="anNewBtn"
|
||||
data-href="<?= e(url('days/new')) ?>"
|
||||
aria-haspopup="dialog" aria-expanded="false">新建纪念日</button>
|
||||
<?php endif; ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('calendar')) ?>">看日历</a>
|
||||
<?php /* 用户第 9 条:设置按钮就在「新建纪念日」右边(☰,排最右) */ ?>
|
||||
@@ -130,6 +135,115 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($canWrite): ?>
|
||||
<?php /* ---- 新建纪念日弹框(内嵌同一份 form.php;初始关闭)---- */ ?>
|
||||
<?php
|
||||
$anModalOnly = true;
|
||||
$anOpen = false;
|
||||
$anModal = true;
|
||||
$anEmbed = false;
|
||||
$anShell = true;
|
||||
require __DIR__ . '/form.php';
|
||||
unset($anModalOnly, $anOpen, $anModal, $anEmbed, $anShell);
|
||||
?>
|
||||
<script>
|
||||
/* ==================== v41:新建纪念日弹框(事件委托 + AJAX)====================
|
||||
与观影/旅程同一套做法与理由(详见 _tooling 记忆):
|
||||
· 事件委托(挂 document)—— 校验失败时服务端会把弹框整块重画,
|
||||
旧元素连监听一起丢;委托一次绑定永久有效。
|
||||
· 提交主路走 click(表单外的提交按钮上 document 的 submit 监听收不到)。
|
||||
· 成功后只换 #anListHost,不整页跳。 */
|
||||
(function () {
|
||||
if (!window.fetch || !window.FormData) { return; }
|
||||
function card() { return document.getElementById('anNewCard'); }
|
||||
function setOpen(on) {
|
||||
var c = card();
|
||||
if (!c) { return; }
|
||||
c.hidden = !on;
|
||||
var b = document.getElementById('anNewBtn');
|
||||
if (b) { b.setAttribute('aria-expanded', on ? 'true' : 'false'); }
|
||||
if (on) {
|
||||
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
|
||||
if (window.fbBootAntd) { window.fbBootAntd(); }
|
||||
var t = document.getElementById('an-title');
|
||||
if (t) { t.focus(); }
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) { return; }
|
||||
if (t.closest('#anNewBtn')) { e.preventDefault(); setOpen(true); return; }
|
||||
if (t.closest('#anNewX') || t.closest('#anNewCancel')) { setOpen(false); return; }
|
||||
var c = card();
|
||||
if (c && t === c) { setOpen(false); }
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape') { return; }
|
||||
var c = card();
|
||||
if (!c || c.hidden) { return; }
|
||||
var d = document.querySelector('.ant-picker-dropdown');
|
||||
if (d && !d.classList.contains('ant-picker-dropdown-hidden')) { return; }
|
||||
setOpen(false);
|
||||
});
|
||||
function save() {
|
||||
var form = document.getElementById('anForm');
|
||||
if (!form) { return; }
|
||||
var s = document.getElementById('anNewSubmit');
|
||||
if (s) { s.disabled = true; s.textContent = '保存中…'; }
|
||||
fetch(form.action, {
|
||||
method: 'POST', body: new FormData(form),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
}).then(function (r) {
|
||||
var ct = (r.headers.get('content-type') || '');
|
||||
if (ct.indexOf('application/json') >= 0) {
|
||||
return r.json().then(function (j) {
|
||||
var old = card();
|
||||
if (!old || !j || !j.modal) { return; }
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = j.modal;
|
||||
var fresh = tmp.querySelector('#anNewCard');
|
||||
if (!fresh) { return; }
|
||||
old.parentNode.replaceChild(fresh, old);
|
||||
setOpen(true);
|
||||
});
|
||||
}
|
||||
return r.text().then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
var fresh = doc.getElementById('anListHost');
|
||||
var cur = document.getElementById('anListHost');
|
||||
if (fresh && cur) { cur.innerHTML = fresh.innerHTML; }
|
||||
else { window.location.reload(); return; }
|
||||
var c = card();
|
||||
if (c) { c.hidden = true; }
|
||||
var b = document.getElementById('anNewBtn');
|
||||
if (b) { b.setAttribute('aria-expanded', 'false'); }
|
||||
});
|
||||
}).catch(function () {
|
||||
if (s) { s.disabled = false; s.textContent = '确定'; }
|
||||
form.submit();
|
||||
}).then(function () {
|
||||
if (s) { s.disabled = false; s.textContent = '确定'; }
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function (ev) {
|
||||
var t = ev.target;
|
||||
if (!t || !t.closest || !t.closest('#anNewSubmit')) { return; }
|
||||
ev.preventDefault();
|
||||
save();
|
||||
});
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var f = ev.target;
|
||||
if (!f || f.id !== 'anForm') { return; }
|
||||
ev.preventDefault();
|
||||
save();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* 🔴 v41:列表内容包一层 `#anListHost` —— 弹框里保存成功后前端只换这一块。 */ ?>
|
||||
<div id="anListHost">
|
||||
<div class="card card-pad">
|
||||
<h2 class="cinema-h2">接下来的日子</h2>
|
||||
<?php if (!$upcoming): ?>
|
||||
@@ -185,3 +299,4 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div><?php /* /#anListHost */ ?>
|
||||
@@ -386,20 +386,39 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
|
||||
以后动这里只要保证:**开的数量 = 关的数量,且顺序一致**。 */ ?>
|
||||
<?php if ($mvModal): ?>
|
||||
</div><?php /* /.cal-modal-body(仅弹框形态) */ ?>
|
||||
<?php /* 🔴 v41(用户 2026-10-10):「观影模块弹出的框下面的按钮也是确定和取消。」
|
||||
两处变化:① 提交按钮文案 保存/记下这场电影 → **「确定」**(统一口径);
|
||||
② **顺序改成「确定」在前、「取消」在后**(用户明确"下面的按钮就是确定和取消
|
||||
两个",与旅程那边同一口径)。id 保持不变(详情页的 AJAX 主路挂的是
|
||||
`#mvAddSubmit`,改文案不影响)。 */ ?>
|
||||
<div class="cal-modal-foot">
|
||||
<span class="cal-modal-tip" id="mvAddTip"></span>
|
||||
<button class="btn btn-sm" type="submit" form="mvForm" id="mvAddSubmit"><?= $isEdit ? '保存' : '记下这场电影' ?></button>
|
||||
<button class="btn btn-sm" type="submit" form="mvForm" id="mvAddSubmit">确定</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" id="mvAddCancel">取消</button>
|
||||
</div>
|
||||
</div><?php /* /.cal-modal-panel(仅弹框形态) */ ?>
|
||||
<?php else: ?>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn">保存</button>
|
||||
<?php /* ⚠️ 整页形态的提交按钮**也要 id**:两种形态的按钮语义完全相同,
|
||||
给同一个 id 让"按 id 定位"对两种形态都成立(同一时刻只渲染其一,不会冲突)。
|
||||
v41 实测:探针原来按**文案**("保存")找按钮,用户要求改成「确定」后
|
||||
探针找不到按钮 ⇒ 三条落库断言全 NULL(看着像功能坏了)。⇒ id 才是契约。 */ ?>
|
||||
<button type="submit" class="btn" id="mvAddSubmit">确定</button>
|
||||
<a class="btn btn-ghost"
|
||||
href="<?= e(url('movies/show', ['id' => (int)$m['id']])) ?>">取消</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div><?php /* /.mv-form-inner 或 .card.card-pad(编辑态) */ ?>
|
||||
<?php /* 🔴🔴 v41:**这一层只在整页形态存在**。
|
||||
弹框形态下 `.mv-form-inner` 已经在上面(`</div><!-- /.mv-form-inner -->`)
|
||||
关掉了 —— 原来这里**无条件**又关一次 ⇒ 弹框形态**多一个 `</div>`**,
|
||||
把 `</form>` 挤到了容器外面(`_fb_divdepth.py` 当场抓到:
|
||||
"深度结束时 -1,首个负深度在 `</div></form>`")。
|
||||
⚠️ 这与 v37 那次"保存按钮被甩到 form 外面"是**同一个结构性坑**,
|
||||
而 v40 那轮只跑了 `php -l`(它对此完全无感)所以没发现
|
||||
—— 这一轮补上"按形态逐一量 div 深度"才暴露出来。 */ ?>
|
||||
<?php if (!$mvModal): ?>
|
||||
</div><?php /* /.card.card-pad(整页形态) */ ?>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<?php if ($mvModal): ?>
|
||||
</div><?php /* /.cal-modal(弹框壳) */ ?>
|
||||
@@ -455,6 +474,15 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
|
||||
<label>影院名称 *</label>
|
||||
<input class="input" name="name" required placeholder="如:万象城 CGV" maxlength="100">
|
||||
</div>
|
||||
<?php /* 🔴 v41(用户 2026-10-10):「这里新建影院这个地方好像没有详细地址啊。
|
||||
应该把详细地址加上。」—— 影院管理页的表单一直有 address 字段,
|
||||
但这个**快捷弹框**漏了它 ⇒ 从这里新建的影院永远没有详细地址。
|
||||
字段名与影院管理页保持一致(`address`),服务端 Cinema::save 已经收它。 */ ?>
|
||||
<div class="field">
|
||||
<label>详细地址(可不填)</label>
|
||||
<input class="input" name="address" placeholder="如:香港中路 10 号万象城 4 层"
|
||||
maxlength="200">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>主题颜色</label>
|
||||
<div class="color-row">
|
||||
|
||||
@@ -67,15 +67,19 @@ $wd = static function (?string $d): string {
|
||||
<div class="two-col">
|
||||
<div class="chart-card">
|
||||
<h3>按观影人统计 <span class="hint">参与场次</span></h3>
|
||||
<?php /* 🔴 v41(用户 2026-10-10):「每个人不是根据头像,计算出来一个颜色吗?
|
||||
按观影人统计那里,那个柱状图显示这个人的颜色。」
|
||||
⇒ 用 `AvatarColor::for()`(全站头像配色**唯一来源**)给柱子上色,
|
||||
与这个人在名册/头像圈上的颜色**同一套算法**(同名/同头像必同色)。 */ ?>
|
||||
<?php if ($byPerson): ?>
|
||||
<div class="bar-chart">
|
||||
<?php foreach ($byPerson as $r): ?>
|
||||
<?php foreach ($byPerson as $r): $pcol = \App\AvatarColor::for($r); ?>
|
||||
<div class="bar-item">
|
||||
<span class="bar-label">
|
||||
<img class="bar-avatar" src="<?= e(avatar_url($r['avatar'] ?? null)) ?>" alt="">
|
||||
<?= e(person_name($r)) ?>
|
||||
</span>
|
||||
<span class="bar-track"><span class="bar-fill" style="width: <?= e((string)$fmtPct((int)$r['cnt'], $maxPerson)) ?>%"></span></span>
|
||||
<span class="bar-track"><span class="bar-fill" style="width: <?= e((string)$fmtPct((int)$r['cnt'], $maxPerson)) ?>%; background: <?= e($pcol) ?>"></span></span>
|
||||
<span class="bar-count"><?= (int)$r['cnt'] ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
@@ -87,12 +91,14 @@ $wd = static function (?string $d): string {
|
||||
|
||||
<div class="chart-card">
|
||||
<h3>按年统计 <span class="hint">观影数量</span></h3>
|
||||
<?php /* 🔴 v41:「按年统计,也用不同的颜色来显示。」→ `MovieStats::yearColor()`,
|
||||
按年份在色相环上取点(相邻年份色相拉开,且同一年永远同色)。 */ ?>
|
||||
<?php if ($byYear): ?>
|
||||
<div class="bar-chart">
|
||||
<?php foreach ($byYear as $r): ?>
|
||||
<div class="bar-item">
|
||||
<span class="bar-label"><?= e((string)$r['y']) ?> 年</span>
|
||||
<span class="bar-track"><span class="bar-fill bar-fill-alt" style="width: <?= e((string)$fmtPct((int)$r['cnt'], $maxYear)) ?>%"></span></span>
|
||||
<span class="bar-track"><span class="bar-fill" style="width: <?= e((string)$fmtPct((int)$r['cnt'], $maxYear)) ?>%; background: <?= e(\App\MovieStats::yearColor((int)$r['y'])) ?>"></span></span>
|
||||
<span class="bar-count"><?= (int)$r['cnt'] ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
+134
-23
@@ -91,18 +91,34 @@ $myPerson = (int)\App\Auth::personId();
|
||||
</div>
|
||||
|
||||
<?php if ($canWrite): ?>
|
||||
<div class="card card-pad" id="resAddCard" <?= $isEditing ? '' : 'hidden' ?>>
|
||||
<h2 class="cinema-h2"><?= $isEditing ? '编辑资源' : '新建资源' ?></h2>
|
||||
<form method="post" enctype="multipart/form-data"
|
||||
<?php /* 🔴 v41(用户 2026-10-10 第 9 条):「添加资源,采用弹窗形式,并在新建资源的
|
||||
时候,就确定共享人。排版和观影一样,前面是标题,后面跟着冒号,然后是各类输入框。
|
||||
各类框,放在一个页面,包括共享人和附件,编辑也是一样,直接复用添加这个输入框即可。」
|
||||
|
||||
⇒ 三件事:
|
||||
① 从"页面里可开合的一块"改成**弹框**(`#resAddCard` → `.cal-modal`);
|
||||
② 排版改观影式(`.mv-row`:左标签固定 62px + 右对齐 + 自动冒号);
|
||||
③ **共享人**与**附件**都放进这一张表单(原来共享只在那条的「共享」弹框里做)。
|
||||
⚠️ `$isEditing` 时弹框**自动打开**(从列表点"编辑"进来就是要改它)。 */ ?>
|
||||
<div class="cal-modal" id="resAddCard" role="dialog" aria-modal="true"
|
||||
aria-label="<?= $isEditing ? '编辑资源' : '新建资源' ?>"
|
||||
<?= ($isEditing || !empty($resOpen)) ? '' : 'hidden' ?>>
|
||||
<div class="cal-modal-panel mv-panel">
|
||||
<div class="cal-modal-head">
|
||||
<b id="resAddTitle"><?= $isEditing ? '编辑资源' : '新建资源' ?></b>
|
||||
<button type="button" class="cal-modal-x" id="resAddX" aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" id="resForm"
|
||||
action="<?= e(url($isEditing ? 'resources/update' : 'resources/create')) ?>">
|
||||
<?= csrf_field() ?>
|
||||
<?php if ($isEditing): ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
|
||||
<?php endif; ?>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>归属于谁 *</label>
|
||||
<?php /* v24 第 11 条:与全局选人组件统一(原来是下拉,名册一长就难找) */ ?>
|
||||
<div class="mv-form-inner">
|
||||
<div class="mv-grid">
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">归属于谁<i>*</i></span>
|
||||
<div class="mv-fill">
|
||||
<?php
|
||||
$ppName = 'owner_id';
|
||||
$ppPeople = $people;
|
||||
@@ -114,18 +130,40 @@ $myPerson = (int)\App\Auth::personId();
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="rs-title">标题 *</label>
|
||||
</div>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">标题<i>*</i></span>
|
||||
<input class="input" id="rs-title" name="title" required
|
||||
value="<?= e($editing['title'] ?? '') ?>" placeholder="如:身份证 / 学位证">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="rs-content">正文</label>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">正文</span>
|
||||
<textarea class="input" id="rs-content" name="content" rows="3"
|
||||
placeholder="编号、有效期、放在哪里…"><?= e($editing['content'] ?? '') ?></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="sd-attach">附件(可多选)</label>
|
||||
<?php /* 🔴 v41:**共享给谁**(用户第 9 条"新建的时候就确定共享人")。
|
||||
⚠️ 这里只给"只读" —— 新建时最多到只读;要给人可写,
|
||||
去列表里那条资源的「共享」弹框单独设(那里能选权限)。
|
||||
⚠️ 归属人自己会被服务端自动跳过(不会产生多余共享记录)。 */ ?>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">共享</span>
|
||||
<div class="mv-fill">
|
||||
<?php
|
||||
$ppName = 'share_ids[]';
|
||||
$ppPeople = $people;
|
||||
$ppSelected = array_map('intval', array_column(
|
||||
$isEditing ? \App\Resource::shares((int)$editing['id']) : [], 'person_id'));
|
||||
$ppMode = 'multi';
|
||||
$ppOpenBtn = '+ 共享给谁';
|
||||
$ppTitle = '这条资源共享给谁(只读)';
|
||||
$ppTip = '归属人不用勾(本来就是他的)。要给人「可读写」,到列表里那条的「共享」里设。';
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mv-row mv-row-media">
|
||||
<span class="mv-lb">附件</span>
|
||||
<div class="mv-media-body">
|
||||
<?php /* ⚠️ 新建时资源还不存在,附件只能在这里一起提交(建完再挂)。
|
||||
列表里那条的附件管理则走右上角蓝色的「附件」弹框。
|
||||
两处的文件框 id 必须**不同**(同 id 会撞),所以这里显式给一个。 */
|
||||
@@ -133,16 +171,20 @@ $myPerson = (int)\App\Auth::personId();
|
||||
require __DIR__ . '/../_stage_drop.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit"><?= $isEditing ? '保存' : '新建' ?></button>
|
||||
<?php if ($isEditing): ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('resources')) ?>">取消</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-modal-foot">
|
||||
<span class="cal-modal-tip" id="resAddTip"></span>
|
||||
<button class="btn btn-sm" type="submit" form="resForm" id="resAddSubmit">确定</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" id="resAddCancel">取消</button>
|
||||
</div>
|
||||
</div><?php /* /.cal-modal-panel */ ?>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* 🔴 v41:列表内容包一层 `#resListHost` —— 弹框保存成功后前端只换这一块。 */ ?>
|
||||
<div id="resListHost">
|
||||
<div class="card card-pad">
|
||||
<h2 class="cinema-h2">
|
||||
资源列表
|
||||
@@ -458,19 +500,88 @@ $myPerson = (int)\App\Auth::personId();
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><?php /* /#resListHost */ ?>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
/* ---- 「新建资源」开合(与旧版一致)---- */
|
||||
/* ---- 🔴 v41(用户第 9 条):「新建资源」改成**弹框** ----
|
||||
⚠️ 原来是"页面里可开合的一块"(点一下展开、再点收起)。
|
||||
现在它与「编辑」共用同一个弹框(`#resAddCard`),
|
||||
所以打开时要清空上次的输入(否则会带着上一次的内容)。
|
||||
⚠️ 提交走 AJAX + 只换列表容器:用户没要求,但"弹框里点确定却整页跳走"
|
||||
会丢掉滚动位置;既然列表页已经有 `#resListHost` 这样的容器就顺手接上。 */
|
||||
var addBtn = document.getElementById('resAddBtn');
|
||||
var addCard = document.getElementById('resAddCard');
|
||||
function setResOpen(on) {
|
||||
if (!addCard) { return; }
|
||||
addCard.hidden = !on;
|
||||
if (on) {
|
||||
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
|
||||
if (window.fbBootAntd) { window.fbBootAntd(); }
|
||||
var t = document.getElementById('rs-title');
|
||||
if (t) { t.focus(); }
|
||||
}
|
||||
}
|
||||
if (addBtn && addCard) {
|
||||
addBtn.addEventListener('click', function () {
|
||||
addCard.hidden = !addCard.hidden;
|
||||
addBtn.textContent = addCard.hidden ? '新建资源' : '收起';
|
||||
if (!addCard.hidden) { addCard.scrollIntoView({ block: 'nearest' }); }
|
||||
addBtn.addEventListener('click', function () { setResOpen(true); });
|
||||
}
|
||||
['resAddX', 'resAddCancel'].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) { el.addEventListener('click', function () { setResOpen(false); }); }
|
||||
});
|
||||
if (addCard) {
|
||||
addCard.addEventListener('click', function (e) {
|
||||
if (e.target === addCard) { setResOpen(false); } // 点遮罩关
|
||||
});
|
||||
}
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape' || !addCard || addCard.hidden) { return; }
|
||||
var d = document.querySelector('.ant-picker-dropdown');
|
||||
if (d && !d.classList.contains('ant-picker-dropdown-hidden')) { return; }
|
||||
setResOpen(false);
|
||||
});
|
||||
/* 提交(click 主路 + submit 兜回车;理由见观影/旅程那两处注释) */
|
||||
function resSave() {
|
||||
var form = document.getElementById('resForm');
|
||||
if (!form || !window.fetch || !window.FormData) { return false; }
|
||||
var s = document.getElementById('resAddSubmit');
|
||||
if (s) { s.disabled = true; s.textContent = '保存中…'; }
|
||||
fetch(form.action, {
|
||||
method: 'POST', body: new FormData(form),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
}).then(function (r) {
|
||||
return r.text().then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
var fresh = doc.getElementById('resListHost');
|
||||
var cur = document.getElementById('resListHost');
|
||||
if (fresh && cur) { cur.innerHTML = fresh.innerHTML; }
|
||||
else { window.location.reload(); return; }
|
||||
setResOpen(false);
|
||||
});
|
||||
}).catch(function () {
|
||||
if (s) { s.disabled = false; s.textContent = '确定'; }
|
||||
form.submit();
|
||||
}).then(function () {
|
||||
if (s) { s.disabled = false; s.textContent = '确定'; }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
document.addEventListener('click', function (ev) {
|
||||
var t = ev.target;
|
||||
if (!t || !t.closest || !t.closest('#resAddSubmit')) { return; }
|
||||
if (resSave()) { ev.preventDefault(); }
|
||||
});
|
||||
document.addEventListener('submit', function (ev) {
|
||||
if (ev.target && ev.target.id === 'resForm') {
|
||||
if (resSave()) { ev.preventDefault(); }
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
/* ============================================================
|
||||
动作 → 一份共享弹框(v24 第 23 批 用户第 1 条;
|
||||
v28 第 7 条把「编辑 / 附件 / 共享」合成一个**带页签**的面板)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
/**
|
||||
* 旅程列表**主体**(搜索框 + 列表/空态)。
|
||||
*
|
||||
* 🔴 v41(用户 2026-10-10):单独抽出来,是为了"新建成功后**只换这一块**"
|
||||
* (`#tripListHost` 的 innerHTML)—— 保留滚动位置、不整页闪白。
|
||||
* 所以要保证:这块里的内容**只依赖** $rows / $total / $keyword / $canWrite。
|
||||
*/
|
||||
?>
|
||||
<form class="search" method="get" action="index.php">
|
||||
<input type="hidden" name="r" value="trips">
|
||||
<input class="input" type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜旅程名 / 去过的城市">
|
||||
<button class="btn" type="submit">搜索</button>
|
||||
<?php if ($keyword !== ''): ?>
|
||||
<a class="search-clear" href="<?= e(url('trips')) ?>">清除</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<?php if (!$rows): ?>
|
||||
<div class="card card-pad empty">
|
||||
<b><?= $keyword !== '' ? '没有匹配的旅程' : '还没有旅程' ?></b>
|
||||
<?= $keyword !== '' ? '换个关键词试试,或清除搜索看全部。'
|
||||
: ($canWrite ? '点右上角「新建旅程」,把一次出行记下来;进去之后再加一段段行程。'
|
||||
: '家人还没有记录旅程。') ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="trip-list">
|
||||
<?php foreach ($rows as $t): ?>
|
||||
<a class="trip-card" href="<?= e(url('trips/show', ['id' => (int)$t['id']])) ?>">
|
||||
<div class="trip-cover">
|
||||
<?php if (!empty($t['cover_key'])): ?>
|
||||
<img src="<?= e(media_url((string)$t['cover_key'])) ?>" alt="">
|
||||
<?php else: ?>
|
||||
<span class="trip-cover-empty">🧳</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="trip-main">
|
||||
<h2 class="trip-title"><?= e((string)$t['title']) ?></h2>
|
||||
<div class="trip-meta">
|
||||
<?php /* 时间是从各段旅程算的;一段都没有时如实说"还没有旅程" */ ?>
|
||||
<span><?= e(\App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
|
||||
<?php if (!empty($t['city'])): ?>
|
||||
<span>📍 <?= e((string)$t['city']) ?></span>
|
||||
<?php endif; ?>
|
||||
<span><?= (int)($t['node_count'] ?? 0) ?> 段行程</span>
|
||||
</div>
|
||||
<?php if (!empty($t['summary'])): ?>
|
||||
<p class="trip-sum"><?= e(mb_substr((string)$t['summary'], 0, 80, 'UTF-8')) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($t['attendees'])): ?>
|
||||
<div class="trip-people">
|
||||
<?php foreach ($t['attendees'] as $a): ?>
|
||||
<?= person_chip($a) ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -31,6 +31,13 @@ $rvN = (int)($rvNode['revision_n'] ?? 0);
|
||||
<?php /* 实际的起止时间:默认填计划值,方便"只改时间"这种最常见的情况 */ ?>
|
||||
<div class="field dt-range full">
|
||||
<label>实际的开始 / 结束</label>
|
||||
<?php /* ⚠️ v41 边界说明(别以为这里漏了):用户第 7 条要求"用最新控件"的是
|
||||
**添加计划行程 / 实际行程**(那是 `trips/node_new`,走 node_form.php,
|
||||
已经换成 AntD)。**「修正」表单不换** ——
|
||||
它内联在详情页的 `<details>` 里,一段计划行程一个实例、可能同时存在
|
||||
十几个;antd 每个实例都要挂一批 React 组件与 popper 容器,
|
||||
这种"N 份并存"的场景明显不划算。
|
||||
它用的是自建下拉(`_datetime.php`),**本来就能精确到分**,功能不缺。 */ ?>
|
||||
<?php $dtName = 'rev_starts_at'; $dtValue = $rvNode['starts_at'] ?? '';
|
||||
$dtId = $rvPrefix . '-s'; $dtRange = 'start';
|
||||
require __DIR__ . '/../_datetime.php'; ?>
|
||||
|
||||
+122
-38
@@ -1,18 +1,31 @@
|
||||
<?php
|
||||
/**
|
||||
* 新建 / 编辑旅程(v24 第 16 批精简)。
|
||||
* 新建 / 编辑旅程(v24 第 16 批精简;**v41 按观影模块的排版重做**)。
|
||||
*
|
||||
* ⚠️ 术语(用户第 9 条):这一层是**旅程**(容器),里面一段一段的是**行程**。
|
||||
*
|
||||
* 用户第 10 条原话:「新建旅程里面,怎么还有备注啊,同行的人啊,这些都不要了,
|
||||
* 这些都是从行程里面总结出来的。在新建旅程和编辑旅程的时候,都不要了。」
|
||||
* 上一批(第 4 条)已经定过主区就三项:标题 → 题图 → 简介。
|
||||
* 所以现在这张表单**只剩这三项 + 公开开关**:
|
||||
* · 同行的人 —— 删掉(参与人 = 各段行程参与人的合集,在详情页算出来)
|
||||
* · 备注 —— 删掉(用户明确"都不要了")
|
||||
* · 时间 / 城市 —— 本来就不是输入项(从行程派生)
|
||||
* 「公开」保留:它是"这个旅程要不要出现在列表与日历上"的**隐私开关**,
|
||||
* 不是从行程里能总结出来的信息。
|
||||
* 🔴🔴 v41(用户 2026-10-10 第 2 条)原话:
|
||||
* 「旅程模块,新建旅程也采用弹窗的方式。排版的方式和观影模式里面记一下一部电影类似。
|
||||
* 左侧是标题冒号,右侧是输入框。题图也是一样的,把相关的说明放在题图的那个框里边。
|
||||
* 一段介绍也是一样的。公开这个部分,下面的说明去掉吧,什么不勾等于自己留着看什么的,
|
||||
* 这一段东西都去掉。只需要显示在旅程列表与日历里显示这个旅程的这个复选框就可以了,
|
||||
* 也是一样的,公开冒号,后面是那个复选框,两个在一行里边。下面的按钮就是确定和取消两个。
|
||||
* 确定就不用说了,就是添加这个旅程。如果是取消的话,就直接关闭这个弹窗。
|
||||
* 确定完了之后,动态刷新旅程这个列表页面。」
|
||||
*
|
||||
* ⇒ 四条落实:
|
||||
* ① **排版**:整表单改用观影那套 `.mv-row`(左标签固定 62px + 右对齐 + 自动冒号;
|
||||
* 控件占满剩余宽度)—— 不再用"标签一行、输入框一行"的 `.field` 堆叠。
|
||||
* ② **题图/简介的说明文字**:搬进各自的框里(题图提示移到 `.cover-picker` 内部;
|
||||
* 简介不再另起一行写 tip)。
|
||||
* ③ **公开**:把那一整句说明("不勾等于只留着自己看"之类)**整段删掉**,
|
||||
* 只留「公开:☑ 在旅程列表与日历里显示这个旅程」**一行**。
|
||||
* ④ **按钮**:确定 / 取消(取消 = 关弹窗,见本文件的页内脚本)。
|
||||
*
|
||||
* ⚠️ 表单本身**既能当整页也能当弹框**(`$tpModal` 控制):
|
||||
* · 列表页内嵌 → 弹框(用户第 2 条要的就是这个);
|
||||
* · 整页直接访问 `trips/new`(含校验失败重画)→ 仍然整页,
|
||||
* 否则用户提交失败后跳到这一页只会看到一张空白(提示全在弹框里)。
|
||||
*
|
||||
* @param array|null $t 编辑时的旅程;null = 新建
|
||||
* @param array $errors 校验错误
|
||||
@@ -22,7 +35,26 @@ $canWrite = \App\Auth::canWrite();
|
||||
$cvSlot = \App\ImageSlot::get('cover') ?: ['ratio' => '16:9', 'w' => 1600, 'h' => 900,
|
||||
'label' => '题图', 'hint' => ''];
|
||||
$hasCover = $isEdit && !empty($t['cover_key']);
|
||||
|
||||
/* 弹框形态的开关(与观影 form.php 同一套命名习惯):
|
||||
$tpModal —— 是否渲染成弹框壳(含遮罩/标题栏)
|
||||
$tpModalOnly —— 被列表页内嵌(初始关闭,等用户点"新建旅程"才开)
|
||||
$tpOpen —— 初始是否打开(整页直达 / AJAX 校验失败重画时为 true) */
|
||||
$tpModal = !isset($tpModal) ? true : (bool)$tpModal; // v41:默认就是弹框形态
|
||||
$tpModalOnly = !empty($tpModalOnly);
|
||||
$tpOpen = !empty($tpOpen);
|
||||
$tpReload = (string)($tpReload ?? '');
|
||||
/* 🔴🔴 `$tpEmbed`(v41):**只吐表单片段**(详情页弹框 fetch 用)。
|
||||
⇒ 不输出页头、也不输出任何外层容器 —— 壳由详情页的 `#nodeFormHost` 提供。
|
||||
片段再自带一层弹框壳会变成"弹框里套弹框"(遮罩叠遮罩、标题栏双份)。
|
||||
`$tpShell`:本模板**是否输出外层容器**(片段形态 = false;整页/列表内嵌 = true)。
|
||||
⚠️ 外壳**类型**仍由 `$tpModal` 决定(true→`.cal-modal`,false→`.card`)。
|
||||
收尾必须用同一对判据,否则"开头开 .card、结尾收 .cal-modal"→ 深度不配平
|
||||
(2026-10-10 实测被 _fb_divdepth.py 当场抓到)。 */
|
||||
$tpEmbed = !empty($tpEmbed);
|
||||
$tpShell = !isset($tpShell) ? !$tpEmbed : (bool)$tpShell;
|
||||
?>
|
||||
<?php if (!$tpEmbed && !$tpModal): ?>
|
||||
<div class="page-head">
|
||||
<h1><?= $isEdit ? '编辑旅程' : '新建旅程' ?></h1>
|
||||
<span class="sub">旅程里装行程;参与人、城市、天数、花销都从行程里汇总</span>
|
||||
@@ -31,28 +63,54 @@ $hasCover = $isEdit && !empty($t['cover_key']);
|
||||
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">返回</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card card-pad">
|
||||
<form method="post" enctype="multipart/form-data"
|
||||
<?php if ($tpShell): ?>
|
||||
<div class="<?= $tpModal ? 'cal-modal' : 'card card-pad' ?>"<?php
|
||||
if ($tpModal) {
|
||||
echo ' id="tripNewCard" role="dialog" aria-modal="true" aria-label="' . ($isEdit ? '编辑旅程' : '新建旅程') . '"'
|
||||
. ($tpOpen ? '' : ' hidden');
|
||||
} ?>>
|
||||
<?php if ($tpModal): ?>
|
||||
<div class="cal-modal-panel mv-panel">
|
||||
<div class="cal-modal-head">
|
||||
<b><?= $isEdit ? '编辑旅程' : '新建旅程' ?></b>
|
||||
<button type="button" class="cal-modal-x" id="tripNewX" aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" id="tripForm"
|
||||
class="<?= $tpModal ? 'mv-modal-form' : '' ?>"
|
||||
action="<?= e(url($isEdit ? 'trips/update' : 'trips/create')) ?>">
|
||||
<?= csrf_field() ?>
|
||||
<?php if ($isEdit): ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($tpModal && !$isEdit): ?>
|
||||
<?php /* 从列表页弹框新建成功 → 回到**列表页**并刷新(用户第 2 条:
|
||||
"确定完了之后,动态刷新旅程这个列表页面")。 */ ?>
|
||||
<input type="hidden" name="back" value="trips">
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ==================== ① 标题 ==================== */ ?>
|
||||
<div class="field full">
|
||||
<label for="t-title">这个旅程叫什么 *</label>
|
||||
<div class="mv-form-inner">
|
||||
<div class="mv-grid">
|
||||
<?php /* ==================== ① 标题(左标签 + 右输入框,观影同款)==================== */ ?>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">旅程名<i>*</i></span>
|
||||
<input class="input" id="t-title" name="title" required autofocus
|
||||
value="<?= e(old('title', $t['title'] ?? '')) ?>" placeholder="如:青岛之行 · 2026 暑假">
|
||||
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* ==================== ② 题图(上传 + 16:9 裁剪)====================
|
||||
<?php /* ==================== ② 题图(说明搬进框里)====================
|
||||
⚠️ data-pick-input 让 app.js 的通用逻辑接管"点图 = 开文件框" ——
|
||||
少了它会"看着能点、点了没反应"(第 15 批用户报过的那个 bug)。 */ ?>
|
||||
<div class="field full">
|
||||
<label>题图</label>
|
||||
少了它会"看着能点、点了没反应"(第 15 批用户报过的那个 bug)。
|
||||
🔴 v41:用户要求"把相关的说明放在题图的那个框里边" ⇒
|
||||
这段 `.cover-picker-hint` 现在**在** `.cover-picker` 内部(原来在外面)。 */ ?>
|
||||
<div class="mv-row mv-row-media">
|
||||
<span class="mv-lb">题图</span>
|
||||
<div class="mv-media-body">
|
||||
<div class="cover-picker">
|
||||
<img id="cover-preview"
|
||||
src="<?= e($hasCover ? media_url((string)$t['cover_key']) : '') ?>"
|
||||
@@ -68,33 +126,33 @@ $hasCover = $isEdit && !empty($t['cover_key']);
|
||||
data-crop-preview="#cover-preview">
|
||||
<?php /* 用户挑的裁剪框(归一化 0..1);没裁就留空 = 服务端按 16:9 居中裁 */ ?>
|
||||
<input type="hidden" name="cover_crop" value="">
|
||||
<span class="avatar-hint small muted">
|
||||
点左边那块选题图,选完弹裁剪框。<br>
|
||||
题图是 16:9 的宽幅横图,详情页也按 16:9 显示。
|
||||
<?php if ($hasCover): ?>不换图就不用管这里。<?php endif; ?>
|
||||
<span class="cover-picker-hint">
|
||||
点击或拖动进行上传<br>
|
||||
16:9 宽幅横图,详情页也按 16:9 显示<?php if ($hasCover): ?>;不换图就不用管<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ==================== ③ 简介 ==================== */ ?>
|
||||
<div class="field full">
|
||||
<label for="t-sum">一段简介</label>
|
||||
<?php /* ==================== ③ 一段介绍(观影的"简介"同款:左标签 + 右多行框)==================== */ ?>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">一段介绍</span>
|
||||
<textarea class="input" id="t-sum" name="summary" rows="3"
|
||||
placeholder="这趟玩得怎么样、为什么想记下来"><?= e(old('summary', $t['summary'] ?? '')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php /* ---- 公开开关(隐私开关,不是"从行程总结"出来的信息)----
|
||||
<?php /* ---- 🔴 v41:公开 = **一行**(左标签 + 右复选框),
|
||||
下面的说明文字按用户要求**整段删除**(只留复选框那一行)。
|
||||
⚠️ 先放 hidden=0:不勾选时提交的是"明确的不公开",而不是"这个字段没出现"
|
||||
(后者会被当成"没传"而默认公开,于是"取消勾选"看起来毫无作用)。 */ ?>
|
||||
(后者会被当成"没传"而默认公开,于是"取消勾选"看起来毫无作用)。 ---- */ ?>
|
||||
<?php $isPub = old('is_public', (string)($t['is_public'] ?? 1)) !== '0'; ?>
|
||||
<div class="field full">
|
||||
<label>公开</label>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">公开</span>
|
||||
<label class="chk-inline">
|
||||
<input type="hidden" name="is_public" value="0">
|
||||
<input type="checkbox" name="is_public" value="1" <?= $isPub ? 'checked' : '' ?>>
|
||||
<span>在旅程列表与日历里显示这个旅程</span>
|
||||
</label>
|
||||
<span class="tip">不勾 = 只留着自己看:不进列表、不上日历,但有链接仍能打开。</span>
|
||||
</div>
|
||||
|
||||
<?php if ($isEdit): ?>
|
||||
@@ -108,18 +166,44 @@ $hasCover = $isEdit && !empty($t['cover_key']);
|
||||
<p class="form-note">
|
||||
当前:<b><?= e($tSpan) ?></b><?php if ($tCity !== ''): ?> · 去过 <b><?= e($tCity) ?></b><?php endif; ?>
|
||||
<?php if ($tN > 0): ?> · <b><?= $tN ?></b> 人参与<?php endif; ?>
|
||||
<br>这些都是从下面各段行程算出来的,这里改不了 —— 要改就去改对应的那段行程。
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<p class="form-note">
|
||||
参与人、城市、天数、花销都不用填:加进一段段<b>行程</b>之后会自动汇总。
|
||||
<br>这些都是从各段行程算出来的,这里改不了 —— 要改就去改对应的那段行程。
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div><?php /* /.mv-grid */ ?>
|
||||
</div><?php /* /.mv-form-inner —— ⚠️ 只关这一次! */ ?>
|
||||
|
||||
<?php /* ⚠️🔴 DOM 嵌套必须**按分支配对**:
|
||||
① 按钮行:弹框壳 / 片段自带 / 整页 btn-row 三种;
|
||||
② 收尾:`.cal-modal-panel` 只在 `$tpModal` 时开过 ⇒ **也只在它时才关**。 */ ?>
|
||||
<?php if ($tpShell && $tpModal): ?>
|
||||
<div class="cal-modal-foot">
|
||||
<span class="cal-modal-tip" id="tripNewTip"></span>
|
||||
<button class="btn btn-sm" type="submit" form="tripForm" id="tripNewSubmit">确定</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" id="tripNewCancel">取消</button>
|
||||
</div>
|
||||
</div><?php /* /.cal-modal-panel —— 与开头 `if ($tpModal)` 严格对应 */ ?>
|
||||
<?php elseif ($tpEmbed): ?>
|
||||
<?php /* 🔴 片段形态(详情页弹框 fetch 进来的):**自带按钮行**。
|
||||
详情页那个容器的 `.cal-modal-foot` 是给"行程表单"准备的、
|
||||
按钮 id 也是行程那套;这里自带一份,最不容易接错。 */ ?>
|
||||
<div class="cal-modal-foot">
|
||||
<span class="cal-modal-tip" id="tripNewTip"></span>
|
||||
<button class="btn btn-sm" type="submit" form="tripForm" id="tripNewSubmit">确定</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" data-nf-close>取消</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit"><?= $isEdit ? '保存' : '建好,去加行程' ?></button>
|
||||
<button class="btn" type="submit" id="tripNewSubmit"><?= $isEdit ? '确定' : '建好,去加行程' ?></button>
|
||||
<a class="btn btn-ghost"
|
||||
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">取消</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
<?php /* ⚠️🔴 收尾必须与开头**严格一一对应**(2026-10-10 反复踩到的就是这里):
|
||||
· 弹框(tpShell && tpModal):开头开了 `.cal-modal` → `.cal-modal-panel`
|
||||
(panel 已在 `</form>` 之前关掉了)⇒ 这里只关 `.cal-modal` **一个**;
|
||||
· 整页(tpShell && !tpModal):开头开的是 `.card` ⇒ 关一个;
|
||||
· 片段(!tpShell):开头什么外层都没开 ⇒ 一个都不关。 */ ?>
|
||||
<?php if ($tpShell): ?>
|
||||
</div><?php /* 关掉开头那个外层(.cal-modal 或 .card) */ ?>
|
||||
<?php endif; ?>
|
||||
+128
-51
@@ -20,62 +20,139 @@ $canWrite = \App\Auth::canWrite();
|
||||
<span class="sub">共 <?= (int)$total ?> 个 · 每个旅程里可以有若干段行程</span>
|
||||
<div class="actions">
|
||||
<?php if ($canWrite): ?>
|
||||
<a class="btn" href="<?= e(url('trips/new')) ?>">新建旅程</a>
|
||||
<?php /* 🔴 v41(用户 2026-10-10):新建旅程改成**弹框**(原来是跳 `trips/new` 页)。
|
||||
⚠️ 保留 `data-href`:JS 没跑时它仍是可直接访问的链接(渐进增强)。
|
||||
⚠️ 用 `<button>` 而不是 `<a>` —— 点它不跳页,只开弹框。 */ ?>
|
||||
<button class="btn" type="button" id="tripNewBtn"
|
||||
data-href="<?= e(url('trips/new')) ?>"
|
||||
aria-haspopup="dialog" aria-expanded="false">新建旅程</button>
|
||||
<?php else: ?>
|
||||
<span class="link-quiet">只读账号</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="search" method="get" action="index.php">
|
||||
<input type="hidden" name="r" value="trips">
|
||||
<input class="input" type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜旅程名 / 去过的城市">
|
||||
<button class="btn" type="submit">搜索</button>
|
||||
<?php if ($keyword !== ''): ?>
|
||||
<a class="search-clear" href="<?= e(url('trips')) ?>">清除</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<?php /* ---- 列表主体:包一层 id,供"新建成功后**动态刷新**"换掉(用户第 2 条)---- */ ?>
|
||||
<div id="tripListHost">
|
||||
<?php $tripListBody = true; require __DIR__ . '/_list_body.php'; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!$rows): ?>
|
||||
<div class="card card-pad empty">
|
||||
<b><?= $keyword !== '' ? '没有匹配的旅程' : '还没有旅程' ?></b>
|
||||
<?= $keyword !== '' ? '换个关键词试试,或清除搜索看全部。'
|
||||
: ($canWrite ? '点右上角「新建旅程」,把一次出行记下来;进去之后再加一段段行程。'
|
||||
: '家人还没有记录旅程。') ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="trip-list">
|
||||
<?php foreach ($rows as $t): ?>
|
||||
<a class="trip-card" href="<?= e(url('trips/show', ['id' => (int)$t['id']])) ?>">
|
||||
<div class="trip-cover">
|
||||
<?php if (!empty($t['cover_key'])): ?>
|
||||
<img src="<?= e(media_url((string)$t['cover_key'])) ?>" alt="">
|
||||
<?php else: ?>
|
||||
<span class="trip-cover-empty">🧳</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="trip-main">
|
||||
<h2 class="trip-title"><?= e((string)$t['title']) ?></h2>
|
||||
<div class="trip-meta">
|
||||
<?php /* 时间是从各段旅程算的;一段都没有时如实说"还没有旅程" */ ?>
|
||||
<span><?= e(\App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
|
||||
<?php if (!empty($t['city'])): ?>
|
||||
<span>📍 <?= e((string)$t['city']) ?></span>
|
||||
<?php endif; ?>
|
||||
<span><?= (int)($t['node_count'] ?? 0) ?> 段行程</span>
|
||||
</div>
|
||||
<?php if (!empty($t['summary'])): ?>
|
||||
<p class="trip-sum"><?= e(mb_substr((string)$t['summary'], 0, 80, 'UTF-8')) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($t['attendees'])): ?>
|
||||
<div class="trip-people">
|
||||
<?php foreach ($t['attendees'] as $a): ?>
|
||||
<?= person_chip($a) ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if ($canWrite): ?>
|
||||
<?php /* ---- 新建旅程弹框(内嵌同一份 form.php;初始关闭)---- */ ?>
|
||||
<?php
|
||||
$tpModalOnly = true;
|
||||
$tpOpen = false;
|
||||
$tpModal = true;
|
||||
$tIn = null; // 新建
|
||||
$errorsIn = [];
|
||||
require __DIR__ . '/form.php';
|
||||
unset($tpModalOnly, $tpOpen, $tpModal, $tIn, $errorsIn);
|
||||
?>
|
||||
<script>
|
||||
/* ==================== 新建旅程:弹框 + AJAX + 成功后刷新列表 ====================
|
||||
用户第 2 条:「下面的按钮就是确定和取消两个…如果是取消的话,就直接关闭这个弹窗。
|
||||
确定完了之后,动态刷新旅程这个列表页面。」
|
||||
|
||||
🔴 与观影详情页编辑弹框同一套做法,理由是**同一批坑**:
|
||||
· 事件委托(挂 document)—— 校验失败时服务端会把弹框整块重画,
|
||||
旧元素连监听一起丢掉;委托则一次绑定、永久有效。
|
||||
· `<button form="tripForm">` 在表单**外面**:实测点它浏览器会提交,
|
||||
但挂在 document 上的 submit 监听**收不到事件** ⇒ 主路必须走 click。
|
||||
· 成功后只换 `#tripListHost` 的内容(保留滚动位置、不闪白)。 */
|
||||
(function () {
|
||||
if (!window.fetch || !window.FormData) { return; } // 老浏览器:保持原生整页提交
|
||||
function card() { return document.getElementById('tripNewCard'); }
|
||||
function setOpen(on) {
|
||||
var c = card();
|
||||
if (!c) { return; }
|
||||
c.hidden = !on;
|
||||
var b = document.getElementById('tripNewBtn');
|
||||
if (b) { b.setAttribute('aria-expanded', on ? 'true' : 'false'); }
|
||||
if (on) {
|
||||
/* 弹框是动态插入的:日期/选人/上传那些组件要在这一刻升级 */
|
||||
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
|
||||
if (window.fbBootAntd) { window.fbBootAntd(); }
|
||||
var t = document.getElementById('t-title');
|
||||
if (t) { t.focus(); }
|
||||
}
|
||||
}
|
||||
function busy(on) {
|
||||
var s = document.getElementById('tripNewSubmit');
|
||||
if (s) { s.disabled = !!on; s.textContent = on ? '保存中…' : '确定'; }
|
||||
}
|
||||
/* 打开 / 关闭(委托) */
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) { return; }
|
||||
if (t.closest('#tripNewBtn')) { e.preventDefault(); setOpen(true); return; }
|
||||
if (t.closest('#tripNewX') || t.closest('#tripNewCancel')) { setOpen(false); return; }
|
||||
var c = card();
|
||||
if (c && t === c) { setOpen(false); } // 点遮罩关;点面板内部不关
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape') { return; }
|
||||
var c = card();
|
||||
if (!c || c.hidden) { return; }
|
||||
var d = document.querySelector('.ant-picker-dropdown');
|
||||
if (d && !d.classList.contains('ant-picker-dropdown-hidden')) { return; } // 交给 antd
|
||||
setOpen(false);
|
||||
});
|
||||
/* 确定:提交(click 主路 + submit 兜回车) */
|
||||
function save() {
|
||||
var form = document.getElementById('tripForm');
|
||||
if (!form) { return; }
|
||||
busy(true);
|
||||
fetch(form.action, {
|
||||
method: 'POST', body: new FormData(form),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
redirect: 'follow',
|
||||
}).then(function (r) {
|
||||
var ct = (r.headers.get('content-type') || '');
|
||||
if (ct.indexOf('application/json') >= 0) {
|
||||
/* 校验失败:服务端回重画后的弹框 → 整块换掉(红字/回填由模板产出) */
|
||||
return r.json().then(function (j) {
|
||||
var old = card();
|
||||
if (!old || !j || !j.modal) { return; }
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = j.modal;
|
||||
var fresh = tmp.querySelector('#tripNewCard');
|
||||
if (!fresh) { return; }
|
||||
old.parentNode.replaceChild(fresh, old);
|
||||
setOpen(true);
|
||||
});
|
||||
}
|
||||
/* 成功:跟到最终页(列表),只换列表容器 */
|
||||
return r.text().then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
var freshList = doc.getElementById('tripListHost');
|
||||
var curList = document.getElementById('tripListHost');
|
||||
if (freshList && curList) { curList.innerHTML = freshList.innerHTML; }
|
||||
else { window.location.reload(); return; }
|
||||
/* flash 在正文容器里、会随列表一起换掉;但"新弹框"要复位成关闭 */
|
||||
var c = card();
|
||||
if (c) { c.hidden = true; }
|
||||
var b = document.getElementById('tripNewBtn');
|
||||
if (b) { b.setAttribute('aria-expanded', 'false'); }
|
||||
});
|
||||
}).catch(function () {
|
||||
busy(false);
|
||||
form.submit(); // 退回原生提交(不静默吞)
|
||||
}).then(function () { busy(false); });
|
||||
}
|
||||
document.addEventListener('click', function (ev) {
|
||||
var t = ev.target;
|
||||
if (!t || !t.closest || !t.closest('#tripNewSubmit')) { return; }
|
||||
ev.preventDefault();
|
||||
save();
|
||||
});
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var f = ev.target;
|
||||
if (!f || f.id !== 'tripForm') { return; }
|
||||
ev.preventDefault();
|
||||
save();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
@@ -48,6 +48,14 @@ if (!isset(\App\Trip::NODE_STAGES[$stage])) { $stage = 'plan'; }
|
||||
$curType = (string)($n['node_type'] ?? '');
|
||||
$curMode = (string)($n['travel_mode'] ?? '');
|
||||
$picked = array_map('intval', array_column((array)($n['members'] ?? []), 'id'));
|
||||
/* 🔴 v41(用户 2026-10-10 第 7 条):「参与的人那里默认带上自己。」
|
||||
⚠️ 只对**新建**生效:编辑时必须如实反映已存的那批人
|
||||
(硬塞自己进去会让"我把自己取消了"这个操作永远无效)。
|
||||
与观影"同行的人默认带上自己"(v38)同一口径。 */
|
||||
if (!$isEdit && !$picked) {
|
||||
$mine = (int)\App\Auth::personId();
|
||||
if ($mine > 0) { $picked = [$mine]; }
|
||||
}
|
||||
$back = url('trips/show', ['id' => (int)$ev['id']]);
|
||||
|
||||
// 车辆下拉:只有**自驾**才显示(用户第 3 条)。服务端先按已存值决定初始显隐,
|
||||
@@ -72,7 +80,39 @@ $toRow = [
|
||||
'city' => (string)($n['to_city'] ?? ''),
|
||||
];
|
||||
$amap = \App\Geo::jsConfig();
|
||||
|
||||
/* 🔴🔴 v41(用户 2026-10-10 第 5/6 条):
|
||||
「编辑行程、加计划行程、加实际行程,全部都采用弹窗的形式。其排版的方式和添加电影
|
||||
是一样的,前面是标题冒号,后面是输入框,和观影模块一样,注意排版。」
|
||||
「行程部分的编辑也是采用弹窗的形式。」
|
||||
|
||||
⇒ 这份表单现在**既能当整页、也能当弹框**(`$nfModal` 控制),三种入口共用它。
|
||||
|
||||
⚠️🔴 关键设计:**详情页里只放一个弹框容器**,表单内容按需(点击时)由服务端
|
||||
AJAX 取回。**不要**为每段行程内嵌一份表单 —— 一段行程十几组字段、
|
||||
详情页可能有十几段,内嵌 N 份会把页面撑到几屏长,而且元素 id 会互相撞车。
|
||||
**那正是用户当年说"编辑行程把页面撑破"的成因**(见本文件头部注释)。
|
||||
做法与观影详情页的编辑弹框一致:容器 + fetch 片段 + 事件委托。 */
|
||||
$nfModal = !isset($nfModal) ? false : (bool)$nfModal;
|
||||
$nfModalOnly = !empty($nfModalOnly);
|
||||
$nfOpen = !empty($nfOpen);
|
||||
$nfReload = (string)($nfReload ?? ''); // 成功后要刷新的容器 id(弹框场景用)
|
||||
/* 🔴🔴 `$nfShell`(v41 关键):
|
||||
· 整页 `trips/node_new` 直达、且要显示成弹框 → **要壳**(`.cal-modal` 包住自己);
|
||||
· 详情页弹框里 **fetch 片段**(`?embed=1`)→ **不要壳**!
|
||||
因为详情页已经有一个 `#nodeFormHost` 弹框容器(含遮罩 + 标题栏),
|
||||
片段只需吐"表单本身",再由容器把它塞进 `#nodeFormBody`。
|
||||
片段再自带一层 `.cal-modal` 会变成"弹框里再套一个弹框"
|
||||
(遮罩叠遮罩、标题栏双份、高度算错)—— 这是最容易犯的错。
|
||||
⇒ 由路由传 `$nfShell` 明确区分,**不靠猜**。 */
|
||||
$nfShell = !isset($nfShell) ? $nfModal : (bool)$nfShell;
|
||||
/* `$nfEmbed`:是否"只吐表单片段"(详情页弹框 fetch 用)。
|
||||
⇒ 片段形态既不输出页头、也不输出弹框壳(壳由详情页容器提供)。 */
|
||||
$nfEmbed = !empty($nfEmbed);
|
||||
?>
|
||||
<?php if (!$nfEmbed): ?>
|
||||
<?php /* 整页形态:给一个页头(标题 + 返回),弹框本体随后以**打开**状态渲染。
|
||||
⚠️ `?embed=1`(详情页弹框 fetch 片段)时**整段跳过** —— 那一路只要表单本身。 */ ?>
|
||||
<div class="page-head">
|
||||
<h1>
|
||||
<?= $isEdit ? '编辑行程' : ('添加' . ($stage === 'actual' ? '实际' : '计划') . '行程') ?>
|
||||
@@ -85,9 +125,21 @@ $amap = \App\Geo::jsConfig();
|
||||
<a class="btn btn-ghost" href="<?= e($back) ?>">返回旅程</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card card-pad">
|
||||
<form method="post" enctype="multipart/form-data"
|
||||
<?php if ($nfShell): ?>
|
||||
<div class="cal-modal" id="nodeFormCard" role="dialog" aria-modal="true"
|
||||
aria-label="<?= $isEdit ? '编辑行程' : '添加行程' ?>"<?= $nfOpen ? '' : ' hidden' ?>>
|
||||
<div class="cal-modal-panel mv-panel nf-panel">
|
||||
<div class="cal-modal-head">
|
||||
<b><?= $isEdit ? '编辑行程' : ('添加' . ($stage === 'actual' ? '实际' : '计划') . '行程') ?></b>
|
||||
<button type="button" class="cal-modal-x" data-nf-close aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="nf-wrap<?= $isEdit ? ' is-edit' : '' ?>">
|
||||
<?php endif; ?>
|
||||
<form method="post" enctype="multipart/form-data" id="nodeForm"
|
||||
class="nf-form"
|
||||
action="<?= e(url($isEdit ? 'trips/node_update' : 'trips/node_add')) ?>">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="trip_id" value="<?= (int)$ev['id'] ?>">
|
||||
@@ -96,7 +148,12 @@ $amap = \App\Geo::jsConfig();
|
||||
<?php if ($isEdit): ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($nfModal && $nfReload !== ''): ?>
|
||||
<?php /* 弹框里保存成功 → 服务端回列表容器,由前端换上(见详情页脚本) */ ?>
|
||||
<input type="hidden" name="nf_reload" value="<?= e($nfReload) ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mv-form-inner">
|
||||
<div class="form-grid">
|
||||
<?php /* ---- 类型(TripLog 的第一个字段;第 17 批 第 2 条已补齐到 8 种)---- */ ?>
|
||||
<div class="field">
|
||||
@@ -131,14 +188,20 @@ $amap = \App\Geo::jsConfig();
|
||||
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* ---- 起止时间:一个范围控件(精确到秒,用户第 17 批问"怎么不显示时分秒")---- */ ?>
|
||||
<?php /* ---- 起止时间:一个范围控件(v41 用户第 7 条起改用 **AntD 最新控件**)----
|
||||
用户原话:「添加计划行程和实际行程,里面的时间都按照我们最新的控件,
|
||||
精确到时和分就可以。」
|
||||
⇒ `$dtPlugin='antd'`(与观影同一套):面板里有可点的时/分列 + 中文「确定」;
|
||||
`$dtSeconds` 保持默认 false = **精确到分**(正是用户要的粒度)。
|
||||
⚠️ 隐藏字段仍是 `*_date/_hour/_minute`,提交契约一字未改;
|
||||
antd 拉不动时自动退回自建下拉(渐进增强)。 */ ?>
|
||||
<div class="field dt-range full">
|
||||
<label>开始 / 结束</label>
|
||||
<?php $dtName = 'starts_at'; $dtValue = $n['starts_at'] ?? '';
|
||||
$dtId = 'nf-s'; $dtRange = 'start';
|
||||
$dtId = 'nf-s'; $dtRange = 'start'; $dtPlugin = 'antd';
|
||||
require __DIR__ . '/../_datetime.php'; ?>
|
||||
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
|
||||
$dtId = 'nf-e'; $dtRange = 'end';
|
||||
$dtId = 'nf-e'; $dtRange = 'end'; $dtPlugin = 'antd';
|
||||
require __DIR__ . '/../_datetime.php'; ?>
|
||||
<span class="tip">结束时间可不填;填到几点几分,详情页就显示到几点几分。</span>
|
||||
</div>
|
||||
@@ -302,14 +365,31 @@ $amap = \App\Geo::jsConfig();
|
||||
require __DIR__ . '/../_stage_drop.php'; ?>
|
||||
<span class="tip">可多选;图片、视频都会存到这段行程下面。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div><?php /* /.form-grid */ ?>
|
||||
</div><?php /* /.mv-form-inner(⚠️ 只关这一次) */ ?>
|
||||
|
||||
<?php if ($nfShell): ?>
|
||||
<div class="cal-modal-foot">
|
||||
<span class="cal-modal-tip" id="nodeFormTip"></span>
|
||||
<button class="btn btn-sm" type="submit" form="nodeForm" id="nodeFormSubmit">确定</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" data-nf-close>取消</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加这段行程' ?></button>
|
||||
<a class="btn btn-ghost" href="<?= e($back) ?>">取消</a>
|
||||
<button class="btn" type="submit" id="nodeFormSubmit">确定</button>
|
||||
<button class="btn btn-ghost" type="button" data-nf-close>取消</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
<?php /* ⚠️🔴 两个形态**各收各的外层容器**(2026-10-10 实测:只写一支会让
|
||||
另一支多/少一个 `</div>`,元素跑到容器外)。 */ ?>
|
||||
<?php if (!$nfShell): ?>
|
||||
</div><?php /* /.nf-wrap(整页 / 片段形态) */ ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($nfShell): ?>
|
||||
</div><?php /* /.cal-modal-panel */ ?>
|
||||
</div><?php /* /.cal-modal */ ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ---- 删除照片用的**独立表单**(在外层行程表单之外)----
|
||||
见上面 photo-grid 的说明:用 `form="id"` 把按钮关联到这里,
|
||||
|
||||
+238
-32
@@ -46,39 +46,41 @@ $groupMeta = [
|
||||
<h1><?= e((string)$t['title']) ?></h1>
|
||||
<?php /* ⚠️ 用户第 6 条:标题旁边**不放**时间。时间在下面题图右侧的信息区里。 */ ?>
|
||||
<div class="actions">
|
||||
<?php /* 用户第 19 批 第 2 条:「旅程详情页面,增加返回按钮,点击后返回到旅程列表。」
|
||||
按全站规矩放在动作区最左(右起是:设置 → 删除 → 动作),
|
||||
文案与观影页统一成「返回旅程」。 */ ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
|
||||
<?php if ($canWrite): ?>
|
||||
<?php /* 用户第 6 条:添加计划行程 / 实际行程的按钮都放**右上角**,
|
||||
点击打开**新页面**(第 5 条:不再内联,内联会把页面撑破)。 */ ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'plan'])) ?>">
|
||||
+ 计划行程
|
||||
</a>
|
||||
<a class="btn" href="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'actual'])) ?>">
|
||||
+ 实际行程
|
||||
</a>
|
||||
<a class="btn btn-ghost" href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">编辑</a>
|
||||
<?php /* 🔴 v24 第 21 批 第 6 条:**设置按钮必须排到最后**。
|
||||
用户原话:「旅程的详情页面,右上角的按钮,设置在最右侧,和其他模块保持统一。」
|
||||
|
||||
⚠️ 原来这里的顺序是「… → 设置(☰) → 删除(🗑)」—— 设置被删除键挤到了**倒数第二**,
|
||||
所以它并**不在最右**(实测:动作区最右边缘贴着的是那个删除表单,
|
||||
而设置按钮左边还空着一大截)。全站其它模块(观影 / 日程 / 任务 / 资源)
|
||||
的约定都是「设置 ☰ 恒为动作区最后一个」,用户点惯了一个位置,
|
||||
到这里却偏了一格,就是"不统一"的全部含义。
|
||||
⚠️ 所以删除键挪到设置**前面**(它现在是倒数第二)。 */ ?>
|
||||
<?php /* 🔴🔴 v41(用户 2026-10-10 第 4 条):
|
||||
「旅程详情页面右上角的按钮,顺序分别是从左到右:
|
||||
+计划行程、+实际行程、编辑、删除、返回旅程和设置。
|
||||
计划行程的按钮用蓝色背景;实际行程的按钮用绿色背景;
|
||||
编辑用黄色背景;删除用红色背景;设置就用这个图标,不要变。」
|
||||
⇒ 两条硬要求:
|
||||
① **顺序**:+计划行程 → +实际行程 → 编辑 → 删除 → 返回旅程 → 设置(☰);
|
||||
② **配色**:计划=蓝、实际=绿、编辑=黄、删除=红;设置保持图标不变。
|
||||
⚠️ 之前是「返回旅程在最左、其余 ghost/实心混着」,与本条**完全相反** ——
|
||||
所以这一次是**按新顺序重排整段**,不是微调。
|
||||
⚠️ 原来"+计划行程/编辑"是 `<a>`(跳页面),现在全部改成**弹框**(见第 5 条),
|
||||
所以这里用 `<button type="button">` + data 属性,由页内脚本打开弹框。 */ ?>
|
||||
<button class="btn btn-plan" type="button"
|
||||
data-node-open="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'plan', 'embed' => 1])) ?>"
|
||||
aria-haspopup="dialog" aria-expanded="false">+ 计划行程</button>
|
||||
<button class="btn btn-actual" type="button"
|
||||
data-node-open="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'actual', 'embed' => 1])) ?>"
|
||||
aria-haspopup="dialog" aria-expanded="false">+ 实际行程</button>
|
||||
<button class="btn btn-edit" type="button"
|
||||
data-trip-edit="<?= e(url('trips/edit', ['id' => (int)$t['id'], 'embed' => 1])) ?>"
|
||||
aria-haspopup="dialog" aria-expanded="false">编辑</button>
|
||||
<form method="post" action="<?= e(url('trips/destroy')) ?>" class="res-inline"
|
||||
onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?里面的行程、照片会一起删掉,不可恢复。')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
|
||||
<button class="btn btn-ghost btn-icon" type="submit" title="删除这个旅程" aria-label="删除">🗑</button>
|
||||
<button class="btn btn-del" type="submit" title="删除这个旅程">删除</button>
|
||||
</form>
|
||||
<?php /* 用户第 8 条:右上角一个**设置**按钮(车辆管理 / 出行方式 / 节点类型)。
|
||||
全站规矩:设置只有图标(三条横线)、无文字、**恒在动作区最右**。 */ ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
|
||||
<?php /* ⚠️ 设置**恒为动作区最后一个**(全站统一:观影/日程/任务/资源都如此)。
|
||||
用户这次明确"设置就用这个图标,不要变" ⇒ 保持 `_settings_menu.php`
|
||||
原样(纯图标 ☰),一个字都不动。 */ ?>
|
||||
<?php $trSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
|
||||
<?php else: ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
|
||||
<span class="link-quiet">只读账号</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -139,10 +141,12 @@ $groupMeta = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* 参与人是谁:用户点名要("显示的是参与人都有谁") */ ?>
|
||||
<?php /* 参与人是谁:用户点名要("显示的是参与人都有谁")。
|
||||
🔴 v41(用户 2026-10-10):标题后面那个括号(以及括号里那句解释)不要了。
|
||||
这个括号连带里面的内容都不要了。」⇒ 只留标题四个字。 */ ?>
|
||||
<?php if ($people): ?>
|
||||
<div class="trip-people-block">
|
||||
<span class="small muted">参与的人(各段行程的合集)</span>
|
||||
<span class="small muted">参与的人</span>
|
||||
<div class="viewers-list">
|
||||
<?php foreach ($people as $a): ?>
|
||||
<?= person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?>
|
||||
@@ -191,6 +195,11 @@ $groupMeta = [
|
||||
$shown = 0;
|
||||
foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
?>
|
||||
<?php /* 🔴 v41:行程列表包一层 `#nodeListHost` —— 弹框里保存成功后,
|
||||
前端只换这一块(保留滚动位置、不整页闪白)。
|
||||
⚠️ 注意 `tripListHost`(旅程列表)与 `nodeListHost`(行程列表)是两个
|
||||
不同页面的容器,前端脚本两个 id 都认。 */ ?>
|
||||
<div id="nodeListHost">
|
||||
<?php if ($shown === 0): ?>
|
||||
<div class="card card-pad empty">
|
||||
<b>还没有行程</b>
|
||||
@@ -235,7 +244,25 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
<span class="rev-badge" title="已修正过多次">已修正 <?= (int)$n['revision_n'] ?> 次</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($mode !== ''): ?>
|
||||
<span class="node-mode"><?= e(\App\Trip::modeIcon($mode)) ?> <?= e(\App\Trip::modeWord($mode)) ?></span>
|
||||
<?php
|
||||
/* 🔴 v41(用户 2026-10-10 第 8 条)原话:
|
||||
「行程里面,交通工具的信息放在后面。比如现在的思路是出发去菏泽,
|
||||
后面写赶路,再后面写火车。那就应该把火车的车次号直接放在"火车"
|
||||
这两个字后面。如果是自驾,就应该把自驾的车牌号放在后面。」
|
||||
⇒ 交通方式的**标识**(车次号 / 车牌号)紧跟模式词,同一个 chip:
|
||||
火车 → 「🚄 火车 G1234」;自驾 → 「🚗 自驾 鲁B·12345」。
|
||||
⚠️ 不再把 trip_no 单独丢进下面那排 `.ntag`(那正是"隔了一段"的来源)。
|
||||
⚠️ 自驾取的是**车辆库里的车牌**(`Vehicle::label` 里已含车牌),
|
||||
不是 `trip_no` —— 自驾那条字段本来就不收车次。 */
|
||||
$modeExtra = '';
|
||||
if (\App\Trip::modeNeedsVehicle($mode) && !empty($n['vehicle_plate'])) {
|
||||
$modeExtra = (string)$n['vehicle_plate'];
|
||||
} elseif (!empty($n['trip_no'])) {
|
||||
$modeExtra = (string)$n['trip_no'];
|
||||
}
|
||||
?>
|
||||
<span class="node-mode"><?= e(\App\Trip::modeIcon($mode)) ?> <?= e(\App\Trip::modeWord($mode)) ?><?php
|
||||
if ($modeExtra !== ''): ?><span class="node-mode-no"><?= e($modeExtra) ?></span><?php endif; ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -253,7 +280,9 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
<?php
|
||||
$traffic = [];
|
||||
if (!empty($n['line_name'])) { $traffic[] = (string)$n['line_name']; }
|
||||
if (!empty($n['trip_no'])) { $traffic[] = (string)$n['trip_no']; }
|
||||
/* 🔴 v41 第 8 条:`trip_no` **不再**放这里 —— 它已经跟在
|
||||
「火车 / 飞机」模式词后面了(见上面 `.node-mode-no`)。
|
||||
留在这里会让车次号在**同一个行程行上出现两次**。 */
|
||||
if (!empty($n['carrier'])) { $traffic[] = (string)$n['carrier']; }
|
||||
?>
|
||||
<?php if ($traffic): ?>
|
||||
@@ -358,9 +387,14 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
⚠️ 计划行的删除入口**保留**:`skipNode`(未成行)是主路径,
|
||||
但"记错了、这段根本不该存在"时仍需要真删(hint 里也这么写)。 */ ?>
|
||||
<div class="node-ops">
|
||||
<?php /* 用户第 5 条:**编辑行程打开新页面**(内联的编辑面板会把页面撑破) */ ?>
|
||||
<a class="btn btn-warn btn-sm"
|
||||
href="<?= e(url('trips/node_edit', ['id' => $nid])) ?>">编辑</a>
|
||||
<?php /* 🔴 v41(用户第 5/6 条):编辑/新增行程一律**弹框**。
|
||||
⚠️ 这里只放"触发器"(data-node-open 带 URL),**不内嵌表单** ——
|
||||
详情页可能有十几段行程,内嵌 N 份表单会把页面撑破、
|
||||
元素 id 还会互相撞车(这正是用户当年报的"把页面撑破")。
|
||||
表单由页尾那一个容器按需 AJAX 取回。 */ ?>
|
||||
<button class="btn btn-warn btn-sm" type="button"
|
||||
data-node-open="<?= e(url('trips/node_edit', ['id' => $nid, 'embed' => 1])) ?>"
|
||||
aria-haspopup="dialog" aria-expanded="false">编辑</button>
|
||||
|
||||
<?php if ($sg === 'plan'): ?>
|
||||
<details class="rev-open">
|
||||
@@ -425,3 +459,175 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div><?php /* /#nodeListHost */ ?>
|
||||
|
||||
<?php if ($canWrite): ?>
|
||||
<?php /* ==================== v41:行程 / 旅程编辑的**弹框容器**(单一实例)====================
|
||||
用户第 5/6 条:「编辑行程、加计划行程、加实际行程,全部都采用弹窗的形式。」
|
||||
「行程部分的编辑也是采用弹窗的形式。」
|
||||
「新建旅程…确定完了之后,动态刷新旅程这个列表页面。」
|
||||
|
||||
为什么是**一个容器 + 按需 AJAX**,而不是每段内嵌一份表单:
|
||||
一段行程有十几组字段(类型/时间/出行方式/交通细节/起终点/内容/参与人/照片/状态),
|
||||
详情页可能有十几段。内嵌 N 份 = 页面被撑到几屏长 + 元素 id 互相撞车
|
||||
—— 这正是用户当年报的「点击编辑会把页面撑破」。
|
||||
⇒ 点"编辑 / +计划行程 / +实际行程 / 编辑旅程"时,才去服务端取那**一份**表单片段,
|
||||
塞进这个容器里打开。保存成功后只换行程列表容器,不整页跳。 */ ?>
|
||||
<div class="cal-modal" id="nodeFormHost" role="dialog" aria-modal="true"
|
||||
aria-label="行程" hidden>
|
||||
<div class="cal-modal-panel mv-panel nf-panel" id="nodeFormPanel">
|
||||
<div class="cal-modal-head">
|
||||
<b id="nodeFormTitle">行程</b>
|
||||
<button type="button" class="cal-modal-x" data-nf-close aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<div class="cal-modal-body" id="nodeFormBody">
|
||||
<p class="empty-hint">正在载入…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ==================== v41:行程 / 旅程编辑弹框(事件委托 + AJAX)====================
|
||||
为什么**全程用事件委托**(监听挂 document、用选择器判断来源):
|
||||
· 弹框内容是 AJAX 换进来的 —— 内容一换,原来元素上的监听全丢;
|
||||
委托则"一次绑定、永久有效",换多少次都不用重绑(观影详情页那轮
|
||||
就是在这里踩过"第二次点编辑没反应")。
|
||||
· 列表里每段行程一个按钮,逐个 addEventListener 会绑十几个。
|
||||
为什么提交主路走 **click** 而不是 submit:
|
||||
提交按钮是靠 form 属性关联的(在表单**外面**)。实测浏览器点它会提交,
|
||||
但挂在 document 上的 submit 监听**收不到事件** ⇒ 会整页跳走、红字看不到
|
||||
(观影 v40 同一坑)。click 一定发生,以它为主路;submit 只兜"按回车"。 */
|
||||
(function () {
|
||||
if (!window.fetch || !window.FormData) { return; } // 老浏览器保持原生跳转
|
||||
|
||||
var RELOAD = 'nodeListHost'; // 保存成功后要刷新的容器
|
||||
function host() { return document.getElementById('nodeFormHost'); }
|
||||
function body() { return document.getElementById('nodeFormBody'); }
|
||||
function titleEl(){ return document.getElementById('nodeFormTitle'); }
|
||||
|
||||
function setOpen(on) {
|
||||
var h = host();
|
||||
if (!h) { return; }
|
||||
h.hidden = !on;
|
||||
if (on) {
|
||||
/* 动态插入的控件:日期 / antd / 选人 / 上传都要在这一刻升级 */
|
||||
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
|
||||
if (window.fbBootAntd) { window.fbBootAntd(); }
|
||||
var f = document.getElementById('nf-title');
|
||||
if (f) { f.focus(); }
|
||||
} else if (body()) {
|
||||
body().innerHTML = '<p class="empty-hint">正在载入…</p>';
|
||||
}
|
||||
}
|
||||
function antdOpen() {
|
||||
var d = document.querySelector('.ant-picker-dropdown');
|
||||
return !!(d && !d.classList.contains('antd-picker-dropdown-hidden'));
|
||||
}
|
||||
|
||||
/* ---- 打开:从 data-node-open / data-trip-edit 拿 URL,取片段塞进来 ---- */
|
||||
function loadInto(url, title) {
|
||||
if (titleEl()) { titleEl().textContent = title || '行程'; }
|
||||
if (body()) { body().innerHTML = '<p class="empty-hint">正在载入…</p>'; }
|
||||
setOpen(true);
|
||||
fetch(url, { credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
if (!body()) { return; }
|
||||
body().innerHTML = html;
|
||||
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
|
||||
if (window.fbBootAntd) { window.fbBootAntd(); }
|
||||
var f = document.getElementById('nf-title');
|
||||
if (f) { f.focus(); }
|
||||
})
|
||||
.catch(function () {
|
||||
if (body()) { body().innerHTML = '<p class="empty-hint">载入失败,请重试。</p>'; }
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) { return; }
|
||||
var op = t.closest('[data-node-open]');
|
||||
if (op) {
|
||||
e.preventDefault();
|
||||
var url = op.getAttribute('data-node-open') || '';
|
||||
var isNew = url.indexOf('node_new') >= 0;
|
||||
loadInto(url, isNew ? (op.textContent || '').trim() : '编辑行程');
|
||||
return;
|
||||
}
|
||||
var te = t.closest('[data-trip-edit]');
|
||||
if (te) {
|
||||
e.preventDefault();
|
||||
loadInto(te.getAttribute('data-trip-edit'), '编辑旅程');
|
||||
return;
|
||||
}
|
||||
if (t.closest('[data-nf-close]')) { setOpen(false); return; }
|
||||
var h = host();
|
||||
if (h && t === h) { setOpen(false); return; } // 点遮罩关;点面板内部不关
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape') { return; }
|
||||
var h = host();
|
||||
if (!h || h.hidden) { return; }
|
||||
if (antdOpen()) { return; } // 让 antd 先收它自己的面板
|
||||
setOpen(false);
|
||||
});
|
||||
|
||||
/* ---- 保存:click 主路 + submit 兜回车 ---- */
|
||||
function save() {
|
||||
var form = document.getElementById('nodeForm') || document.getElementById('tripForm');
|
||||
if (!form) { return; }
|
||||
var sub = document.getElementById('nodeFormSubmit')
|
||||
|| document.getElementById('tripNewSubmit');
|
||||
if (sub) { sub.disabled = true; sub.textContent = '保存中…'; }
|
||||
fetch(form.action, {
|
||||
method: 'POST', body: new FormData(form),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
}).then(function (r) {
|
||||
var ct = (r.headers.get('content-type') || '');
|
||||
if (ct.indexOf('application/json') >= 0) {
|
||||
/* 校验失败:服务端回重画后的弹框 → 整块换进容器 */
|
||||
return r.json().then(function (j) {
|
||||
if (body() && j && j.modal) {
|
||||
body().innerHTML = j.modal;
|
||||
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
|
||||
if (window.fbBootAntd) { window.fbBootAntd(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
/* 成功:服务端 302 回来 → 只换列表容器(保留滚动位置、不闪白) */
|
||||
return r.text().then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
var fresh = doc.getElementById(RELOAD) || doc.getElementById('tripListHost');
|
||||
var cur = document.getElementById(RELOAD) || document.getElementById('tripListHost');
|
||||
if (fresh && cur) { cur.innerHTML = fresh.innerHTML; }
|
||||
else { window.location.reload(); return; }
|
||||
setOpen(false);
|
||||
});
|
||||
}).catch(function () {
|
||||
if (sub) { sub.disabled = false; sub.textContent = '确定'; }
|
||||
form.submit();
|
||||
}).then(function () {
|
||||
if (sub) { sub.disabled = false; sub.textContent = '确定'; }
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function (ev) {
|
||||
var t = ev.target;
|
||||
if (!t || !t.closest) { return; }
|
||||
if (t.closest('#nodeFormSubmit') || t.closest('#tripNewSubmit')) {
|
||||
ev.preventDefault();
|
||||
save();
|
||||
}
|
||||
});
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var f = ev.target;
|
||||
if (!f || (f.id !== 'nodeForm' && f.id !== 'tripForm')) { return; }
|
||||
ev.preventDefault();
|
||||
save();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
Reference in New Issue
Block a user