Files
FamilyBook/templates/trips/form.php
T
laoyang daf5ca904e v94 必填项改成「标签红色加粗」,去掉红色星号 (#3)
需求(用户 2026-10-25):必填项原来在标题后面加一个红色星号,排版经常不齐。
改成「标题本身红色 + 加粗」——用户习惯后,看到红色加粗的标题就知道是必填。

CSS(assets/css/app.css):
- :root 新增 --req:#B03A2E、--req-w:700。
  · 不用 --red(#C0533F):那是动作红(删除按钮底色),当文字色放在暖纸底
    #F7F3EA 上对比度只有 ≈3.4:1,13-14px 标签偏浅;#B03A2E 约 4.6:1。
  · --req-w 必须 700 不能 650/600:本站标签基线字重本来就是 600
    (.form-grid > .field > label / .card .field > label / .mv-row > .mv-lb /
    .dt-range-label 四处都是 600),650 会被取整回 600 = 完全没加粗。
- 文件末尾新增「必填项统一标记」段,覆盖四种标签形态:
  label.is-req / .mv-lb.is-req / .dt-inline-lab.is-req / .dt-range-label.is-req,
  用 !important 打穿各宿主选择器的特异性(颜色与字重是语义标记,不该被面板样式盖掉)。
- 冒号跟着变红:.mv-lb::after / .dt-inline-lab::after 原本是灰色 400,
  只改标签本体会剩「红字 + 灰冒号」的半截效果。
- 包裹层兜底(.field.is-req > label 等),只作用直接子级 ——
  不写 .field.is-req label,那会把「提醒」那排 checkbox 的小标签一起染红。
- 深色主题给备用色 #F08A78。
- 保留 .mv-row > .mv-lb > i(万一有没清干净的星号不至于露灰星花,且静态断言会报出来)。

模板(16 个文件 / 30 处):
- _event_fields.php / _datetime.php 的标签闭包第三参数 $star → $req,
  $dtLabelStar → $dtLabelReq,删掉死引用 $evLabelStar。
- 其余 14 个模板把 `<i>*</i>` 换成给标签加 is-req 类。
- 用显式 .is-req 而不靠 required 属性反推:CSS 无父选择器,:has() 旧 WebView
  不可用,且影院名称/板块名本来就靠 JS 校验没有 required。

验证:
- 静态 _fb_v94_static.py 22/0,反向 9/9(含「--req-w 必须严格大于从 CSS 解析出的
  标签基线字重」,不写死 700)。
- 真浏览器 _fb_v94_req.py 63/0,反向 5/5(加粗/必须红/!important 必要性/
  星号必须消失/每形态单独覆盖,五类注入各自精确命中预期断言)。
- 全量流水线(含 60+ 套件)通过并部署。
- 线上真实页面 _fb_v94_live.py 31/0:观影「片名」、旅程「旅程名」、
  成员「姓名」、导航「网址/所属板块」实测 color=rgb(176,58,46) weight=700,
  同页非必填 color=rgb(44,35,29) weight=600,冒号同色,无星号。
2026-10-26 06:45:38 +08:00

221 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 新建 / 编辑旅程(v24 第 16 批精简;**v41 按观影模块的排版重做**)。
*
* ⚠️ 术语(用户第 9 条):这一层是**旅程**(容器),里面一段一段的是**行程**。
*
* 🔴🔴 v41(用户 2026-10-10 第 2 条)原话:
* 「旅程模块,新建旅程也采用弹窗的方式。排版的方式和观影模式里面记一下一部电影类似。
* 左侧是标题冒号,右侧是输入框。题图也是一样的,把相关的说明放在题图的那个框里边。
* 一段介绍也是一样的。公开这个部分,下面的说明去掉吧,什么不勾等于自己留着看什么的,
* 这一段东西都去掉。只需要显示在旅程列表与日历里显示这个旅程的这个复选框就可以了,
* 也是一样的,公开冒号,后面是那个复选框,两个在一行里边。下面的按钮就是确定和取消两个。
* 确定就不用说了,就是添加这个旅程。如果是取消的话,就直接关闭这个弹窗。
* 确定完了之后,动态刷新旅程这个列表页面。」
*
* ⇒ 四条落实:
* ① **排版**:整表单改用观影那套 `.mv-row`(左标签固定 62px + 右对齐 + 自动冒号;
* 控件占满剩余宽度)—— 不再用"标签一行、输入框一行"的 `.field` 堆叠。
* ② **题图/简介的说明文字**:搬进各自的框里(题图提示移到 `.cover-picker` 内部;
* 简介不再另起一行写 tip)。
* ③ **公开**:把那一整句说明("不勾等于只留着自己看"之类)**整段删掉**,
* 只留「公开:☑ 在旅程列表与日历里显示这个旅程」**一行**。
* ④ **按钮**:确定 / 取消(取消 = 关弹窗,见本文件的页内脚本)。
*
* ⚠️ 表单本身**既能当整页也能当弹框**(`$tpModal` 控制):
* · 列表页内嵌 → 弹框(用户第 2 条要的就是这个);
* · 整页直接访问 `trips/new`(含校验失败重画)→ 仍然整页,
* 否则用户提交失败后跳到这一页只会看到一张空白(提示全在弹框里)。
*
* @param array|null $t 编辑时的旅程;null = 新建
* @param array|null $tIn 【列表页内嵌时用】同上语义
* @param array $errors 校验错误
*
* 🔴🔴 v42(用户 2026-10-11 第 1 条):「新建旅程,把数据带进来了。」
* 真根因:`trips/index.php` 用 `$tIn`(=null)把"编辑数据"传进来,
* 而这份模板读的是 `$t` —— 偏偏 `trips/_list_body.php` 里有个
* `foreach ($rows as $t)`,**循环结束后 `$t` 残留为列表里最后一个旅程**!
* ⇒ 新建弹框被填上了那趟旅程的标题/简介/封面。
* ⇒ 修法(两道保险,且不破坏路由那条路径):
* ① 契约兼容:列表页显式传了 `$tIn` 时**以它为准**(那份才是"要编辑的");
* 路由(trips/new、trips/edit)走的仍是 `$t`,原样不动;
* ② 列表页那边把 `_list_body.php` 的循环变量改名,**不再残留 `$t`**。
*/
if (isset($tIn)) { $t = $tIn; unset($tIn); }
$isEdit = !empty($t);
$canWrite = \App\Auth::canWrite();
$cvSlot = \App\ImageSlot::get('cover') ?: ['ratio' => '16:9', 'w' => 1600, 'h' => 900,
'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>
<div class="actions">
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">返回</a>
</div>
</div>
<?php endif; ?>
<?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="关闭">&#10005;</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; ?>
<div class="mv-form-inner">
<div class="mv-grid">
<?php /* ==================== ① 标题(左标签 + 右输入框,观影同款)==================== */ ?>
<div class="mv-row">
<span class="mv-lb is-req">旅程名</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 /* ==================== ② 题图(说明搬进框里)====================
⚠️ data-pick-input 让 app.js 的通用逻辑接管"点图 = 开文件框" ——
少了它会"看着能点、点了没反应"(第 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']) : '') ?>"
alt="" title="点击选择题图"
data-pick-input="#t-cover"
data-has-cover="<?= $hasCover ? '1' : '0' ?>">
<input class="sr-only" id="t-cover" name="cover" type="file" accept="image/*"
data-crop-ratio="<?= e((string)$cvSlot['ratio']) ?>"
data-crop-w="<?= (int)$cvSlot['w'] ?>"
data-crop-h="<?= (int)$cvSlot['h'] ?>"
data-crop-label="<?= e((string)$cvSlot['label']) ?>"
data-crop-hint="<?= e((string)$cvSlot['hint']) ?>"
data-crop-preview="#cover-preview">
<?php /* 用户挑的裁剪框(归一化 0..1);没裁就留空 = 服务端按 16:9 居中裁 */ ?>
<input type="hidden" name="cover_crop" value="">
<span class="cover-picker-hint">
点击或拖动进行上传<br>
16:9 宽幅横图,详情页也按 16:9 显示<?php if ($hasCover): ?>;不换图就不用管<?php endif; ?>
</span>
</div>
</div>
</div>
<?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 /* ---- 🔴 v41:公开 = **一行**(左标签 + 右复选框),
下面的说明文字按用户要求**整段删除**(只留复选框那一行)。
⚠️ 先放 hidden=0:不勾选时提交的是"明确的不公开",而不是"这个字段没出现"
(后者会被当成"没传"而默认公开,于是"取消勾选"看起来毫无作用)。 ---- */ ?>
<?php $isPub = old('is_public', (string)($t['is_public'] ?? 1)) !== '0'; ?>
<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>
</div>
<?php if ($isEdit): ?>
<?php
/* 编辑时把"已经算出来什么"摆出来:不显示的话,改完标题保存会以为
"我把参与人/时间弄丢了"。 */
$tSpan = \App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null);
$tCity = (string)($t['city'] ?? '');
$tN = count((array)($t['attendees'] ?? []));
?>
<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 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" 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>
<?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; ?>