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:
+143
-86
@@ -1,121 +1,178 @@
|
||||
<?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>
|
||||
<input class="input" id="an-title" name="title" required
|
||||
value="<?= e((string)$v('title')) ?>" placeholder="如:结婚纪念日">
|
||||
</div>
|
||||
<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>
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="an-kind">分类</label>
|
||||
<?php /* 用 <select> 而不是 <input list=…>:分类现在是**用户自己维护的一套**,
|
||||
下拉能保证选到的都是存在的分类;datalist 允许自由填,
|
||||
填出来的新词不会进清单,下次别人也不知道有这个词。 */ ?>
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="an-month">月 *</label>
|
||||
<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>
|
||||
<input class="input" id="an-day" name="day" type="number" min="1" max="31" required
|
||||
value="<?= (int)$v('day', 1) ?>">
|
||||
</div>
|
||||
<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) ?>" 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) ?>" 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)。 */ ?>
|
||||
<?php
|
||||
$ppName = 'person_ids[]';
|
||||
$ppPeople = $people;
|
||||
$ppSelected = (array)($e0['person_ids'] ?? []);
|
||||
if (!$ppSelected && (int)($e0['person_id'] ?? 0) > 0) {
|
||||
$ppSelected = [(int)$e0['person_id']];
|
||||
}
|
||||
$ppMode = 'multi';
|
||||
$ppOpenBtn = '+ 关联人';
|
||||
$ppTitle = '这个纪念日与谁相关';
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
</div>
|
||||
<div class="mv-row">
|
||||
<span class="mv-lb">与谁相关</span>
|
||||
<div class="mv-fill">
|
||||
<?php
|
||||
$ppName = 'person_ids[]';
|
||||
$ppPeople = $people;
|
||||
$ppSelected = (array)($e0['person_ids'] ?? []);
|
||||
if (!$ppSelected && (int)($e0['person_id'] ?? 0) > 0) {
|
||||
$ppSelected = [(int)$e0['person_id']];
|
||||
}
|
||||
$ppMode = 'multi';
|
||||
$ppOpenBtn = '+ 关联人';
|
||||
$ppTitle = '这个纪念日与谁相关';
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="an-leap">农历闰月</label>
|
||||
<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="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>
|
||||
<textarea class="input" id="an-note" name="note" rows="2"><?= e((string)$v('note')) ?></textarea>
|
||||
<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>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit"><?= $editing ? '保存' : '添加' ?></button>
|
||||
<a class="btn btn-ghost" href="<?= e(url('days')) ?>">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
<?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; ?>
|
||||
+117
-2
@@ -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): ?>
|
||||
@@ -184,4 +298,5 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
|
||||
不需要在这里新建,也没法单独删(删掉生日就等于删了它)。
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</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; ?>
|
||||
|
||||
+156
-45
@@ -91,58 +91,100 @@ $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 条:与全局选人组件统一(原来是下拉,名册一长就难找) */ ?>
|
||||
<?php
|
||||
$ppName = 'owner_id';
|
||||
$ppPeople = $people;
|
||||
$ppSelected = [(int)($editing['owner_id'] ?? (int)\App\Auth::personId())];
|
||||
$ppMode = 'single';
|
||||
$ppOpenBtn = '选择归属人';
|
||||
$ppTitle = '资源归属于谁';
|
||||
$ppEmptyText = '未选择';
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="rs-title">标题 *</label>
|
||||
<input class="input" id="rs-title" name="title" required
|
||||
value="<?= e($editing['title'] ?? '') ?>" placeholder="如:身份证 / 学位证">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="rs-content">正文</label>
|
||||
<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 /* ⚠️ 新建时资源还不存在,附件只能在这里一起提交(建完再挂)。
|
||||
列表里那条的附件管理则走右上角蓝色的「附件」弹框。
|
||||
两处的文件框 id 必须**不同**(同 id 会撞),所以这里显式给一个。 */
|
||||
$sdName = 'attach[]'; $sdSlot = 'photo'; $sdId = 'sd-attach';
|
||||
require __DIR__ . '/../_stage_drop.php'; ?>
|
||||
<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;
|
||||
$ppSelected = [(int)($editing['owner_id'] ?? (int)\App\Auth::personId())];
|
||||
$ppMode = 'single';
|
||||
$ppOpenBtn = '选择归属人';
|
||||
$ppTitle = '资源归属于谁';
|
||||
$ppEmptyText = '未选择';
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
</div>
|
||||
</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="mv-row">
|
||||
<span class="mv-lb">正文</span>
|
||||
<textarea class="input" id="rs-content" name="content" rows="3"
|
||||
placeholder="编号、有效期、放在哪里…"><?= e($editing['content'] ?? '') ?></textarea>
|
||||
</div>
|
||||
<?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 会撞),所以这里显式给一个。 */
|
||||
$sdName = 'attach[]'; $sdSlot = 'photo'; $sdId = 'sd-attach';
|
||||
require __DIR__ . '/../_stage_drop.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
</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 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'; ?>
|
||||
|
||||
+179
-95
@@ -1,28 +1,60 @@
|
||||
<?php
|
||||
/**
|
||||
* 新建 / 编辑旅程(v24 第 16 批精简)。
|
||||
* 新建 / 编辑旅程(v24 第 16 批精简;**v41 按观影模块的排版重做**)。
|
||||
*
|
||||
* ⚠️ 术语(用户第 9 条):这一层是**旅程**(容器),里面一段一段的是**行程**。
|
||||
*
|
||||
* 用户第 10 条原话:「新建旅程里面,怎么还有备注啊,同行的人啊,这些都不要了,
|
||||
* 这些都是从行程里面总结出来的。在新建旅程和编辑旅程的时候,都不要了。」
|
||||
* 上一批(第 4 条)已经定过主区就三项:标题 → 题图 → 简介。
|
||||
* 所以现在这张表单**只剩这三项 + 公开开关**:
|
||||
* · 同行的人 —— 删掉(参与人 = 各段行程参与人的合集,在详情页算出来)
|
||||
* · 备注 —— 删掉(用户明确"都不要了")
|
||||
* · 时间 / 城市 —— 本来就不是输入项(从行程派生)
|
||||
* 「公开」保留:它是"这个旅程要不要出现在列表与日历上"的**隐私开关**,
|
||||
* 不是从行程里能总结出来的信息。
|
||||
* 🔴🔴 v41(用户 2026-10-10 第 2 条)原话:
|
||||
* 「旅程模块,新建旅程也采用弹窗的方式。排版的方式和观影模式里面记一下一部电影类似。
|
||||
* 左侧是标题冒号,右侧是输入框。题图也是一样的,把相关的说明放在题图的那个框里边。
|
||||
* 一段介绍也是一样的。公开这个部分,下面的说明去掉吧,什么不勾等于自己留着看什么的,
|
||||
* 这一段东西都去掉。只需要显示在旅程列表与日历里显示这个旅程的这个复选框就可以了,
|
||||
* 也是一样的,公开冒号,后面是那个复选框,两个在一行里边。下面的按钮就是确定和取消两个。
|
||||
* 确定就不用说了,就是添加这个旅程。如果是取消的话,就直接关闭这个弹窗。
|
||||
* 确定完了之后,动态刷新旅程这个列表页面。」
|
||||
*
|
||||
* @param array|null $t 编辑时的旅程;null = 新建
|
||||
* @param array $errors 校验错误
|
||||
* ⇒ 四条落实:
|
||||
* ① **排版**:整表单改用观影那套 `.mv-row`(左标签固定 62px + 右对齐 + 自动冒号;
|
||||
* 控件占满剩余宽度)—— 不再用"标签一行、输入框一行"的 `.field` 堆叠。
|
||||
* ② **题图/简介的说明文字**:搬进各自的框里(题图提示移到 `.cover-picker` 内部;
|
||||
* 简介不再另起一行写 tip)。
|
||||
* ③ **公开**:把那一整句说明("不勾等于只留着自己看"之类)**整段删掉**,
|
||||
* 只留「公开:☑ 在旅程列表与日历里显示这个旅程」**一行**。
|
||||
* ④ **按钮**:确定 / 取消(取消 = 关弹窗,见本文件的页内脚本)。
|
||||
*
|
||||
* ⚠️ 表单本身**既能当整页也能当弹框**(`$tpModal` 控制):
|
||||
* · 列表页内嵌 → 弹框(用户第 2 条要的就是这个);
|
||||
* · 整页直接访问 `trips/new`(含校验失败重画)→ 仍然整页,
|
||||
* 否则用户提交失败后跳到这一页只会看到一张空白(提示全在弹框里)。
|
||||
*
|
||||
* @param array|null $t 编辑时的旅程;null = 新建
|
||||
* @param array $errors 校验错误
|
||||
*/
|
||||
$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>
|
||||
@@ -31,95 +63,147 @@ $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 /* ==================== ① 标题 ==================== */ ?>
|
||||
<div class="field full">
|
||||
<label for="t-title">这个旅程叫什么 *</label>
|
||||
<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 裁剪)====================
|
||||
⚠️ data-pick-input 让 app.js 的通用逻辑接管"点图 = 开文件框" ——
|
||||
少了它会"看着能点、点了没反应"(第 15 批用户报过的那个 bug)。 */ ?>
|
||||
<div class="field full">
|
||||
<label>题图</label>
|
||||
<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="avatar-hint small muted">
|
||||
点左边那块选题图,选完弹裁剪框。<br>
|
||||
题图是 16:9 的宽幅横图,详情页也按 16:9 显示。
|
||||
<?php if ($hasCover): ?>不换图就不用管这里。<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ==================== ③ 简介 ==================== */ ?>
|
||||
<div class="field full">
|
||||
<label for="t-sum">一段简介</label>
|
||||
<textarea class="input" id="t-sum" name="summary" rows="3"
|
||||
placeholder="这趟玩得怎么样、为什么想记下来"><?= e(old('summary', $t['summary'] ?? '')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php /* ---- 公开开关(隐私开关,不是"从行程总结"出来的信息)----
|
||||
⚠️ 先放 hidden=0:不勾选时提交的是"明确的不公开",而不是"这个字段没出现"
|
||||
(后者会被当成"没传"而默认公开,于是"取消勾选"看起来毫无作用)。 */ ?>
|
||||
<?php $isPub = old('is_public', (string)($t['is_public'] ?? 1)) !== '0'; ?>
|
||||
<div class="field full">
|
||||
<label>公开</label>
|
||||
<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): ?>
|
||||
<?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 else: ?>
|
||||
<p class="form-note">
|
||||
参与人、城市、天数、花销都不用填:加进一段段<b>行程</b>之后会自动汇总。
|
||||
</p>
|
||||
<?php if ($tpModal && !$isEdit): ?>
|
||||
<?php /* 从列表页弹框新建成功 → 回到**列表页**并刷新(用户第 2 条:
|
||||
"确定完了之后,动态刷新旅程这个列表页面")。 */ ?>
|
||||
<input type="hidden" name="back" value="trips">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit"><?= $isEdit ? '保存' : '建好,去加行程' ?></button>
|
||||
<a class="btn btn-ghost"
|
||||
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">取消</a>
|
||||
</div>
|
||||
<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 /* ==================== ② 题图(说明搬进框里)====================
|
||||
⚠️ 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>
|
||||
</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(⚠️ 只关这一次) */ ?>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加这段行程' ?></button>
|
||||
<a class="btn btn-ghost" href="<?= e($back) ?>">取消</a>
|
||||
</div>
|
||||
<?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" 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"` 把按钮关联到这里,
|
||||
|
||||
+239
-33
@@ -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">
|
||||
@@ -424,4 +458,176 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
</ol>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<?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