Files
FamilyBook/templates/movies/form.php
T
laoyang 7f00d3e553 feat(kin,movies): 家谱改用第三方库 family-chart;观影设置按钮改纯图标并统一页头度量
家谱(v13):
- 自研布局是死结,不是样式问题:连线的横杆是网格项,只能横跨「父母列→子女列」
  整段区间,中间夹着与血缘无关的人(如媳妇那侧的岳父母)时必然从卡片上穿过。
  换成 family-chart(D3 生态,MIT)后,排布只走本家血脉 + 配偶,姻亲不进画布,
  这个死结在构造上就不存在了。
- 接入踩到并修掉的坑:main_id = data[0].id(「我」必须排最前);
  卡片回调里自定义字段在 d.data.data(双层);库的 family-chart.css 是死文件
  (全挂在 .f3 作用域,运行时没有 .f3 元素);库的 svg 撑满选择器永不命中
  (必须自己补 .kin-chart svg.main_svg);setCanAdd/setOnCardClick 不在 chart 上
  (写错会抛异常,把 updateTree 一起带走 → 整张图空白,页面照样 200)。
- 关掉 setSingleParentEmptyCard:库默认给孩子只登记一位父母的人自动补一张无名
  配偶卡,看着像「凭空多出一个配偶」。
- 卡片用原生 <a> 链成员页,不依赖库的点击回调。

观影按钮(用户本轮原话):
- 「记一场电影」与「设置」高度仍不一致 —— 根因是文字按钮高度由 padding+line-height
  自然推出(43.8px),而 .btn-icon 写死了 40px。抽出 --act-* / --act-h 变量,两边同源。
- 设置按钮去掉「设置」二字,只留图标,图标换成三条横线(☰)。
- 设置按钮在所有页面排最右(.movie-set 用 margin-left:auto 顶到行尾)。
- 除设置外其他按钮都有颜色,与设置区分(主按钮实心 / 次要浅底 / 危险淡红)。
  顺带修掉一个特异性 bug:删除按钮被 .btn 品牌底盖成实心红,与「编辑」难分辨。
- 设置下拉四个条目文字前都加图标(行内 SVG,不引外部字体)。

测试与部署:
- 新增 _fb_kin_check.py(桌面,12 条)+ _fb_kin_falsify.py(6/6)证明
  「整张家谱空白」这类只靠通用几何抓不到的坏法能被抓住。
- _fb_geom_mobile.py 家谱段重写为窄屏语义(库按容器 fit 缩放是正常行为,
  改为断「比例没被压扁 / 缩放不至于看不清」),并加 _fb_kin_mobile_falsify.py(4/4)。
- _fb_btn_check.py(59 条)+ _fb_btn_falsify.py(3/3)覆盖观影页头按钮五条需求。
- familybook_deploy.sh / familybook_smoke.sh 的家谱断言改为第三方库形态,
  设置按钮断言改为 btn-icon 纯图标 + 三条横线 + 条目图标。
- 新增 .gitattributes:vendor 库与 .min.js 标 -text,原样入库不被改写行尾。
2026-10-01 19:35:48 +08:00

456 lines
20 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
/**
* 观影表单(新增 / 编辑,一张表单两用 —— 与 people/form 同一思路)。
*
* v9 变化:观影时间改为「开始 / 结束」两个秒级时刻(_datetime.php 控件,
* 服务端 read_dt_input 取值);评分与观后感**移出表单** —— 搬到详情页的
* 「评价与评论」区(MovieLog 同款:每账号一条评价,谁都能评);影院改为
* 下拉选择已有影院 + 可选「新建影院」行内三段。
*
* 海报与票根是 free 槽位(v8):不进裁剪器、整图等比缩进框内,普通文件控件即可。
* 现场照片只在**新增**时提供多选;编辑时在详情页里逐张补/删。
*/
$isEdit = !empty($m['id']);
// 时间回填:校验失败重画时优先用 old() 里的分字段重组(否则用户刚选的时刻会丢);
// 正常进编辑页时用库里的值。
$dtFromOld = static function (string $name, ?string $dbValue): ?string {
$date = trim((string)old($name . '_date', ''));
if ($date !== '') {
return sprintf('%s %02d:%02d:%02d', $date,
(int)old($name . '_hour', 0), (int)old($name . '_minute', 0), (int)old($name . '_second', 0));
}
return ($dbValue !== null && $dbValue !== '') ? $dbValue : null;
};
$dtStart = $dtFromOld('starts_at', $m['starts_at'] ?? null);
$dtEnd = $dtFromOld('ends_at', $m['ends_at'] ?? null);
$selCinema = (int)old('cinema_id', $m['cinema_id'] ?? 0);
$curAttendees = array_map('intval', array_column($m['attendees'] ?? [], 'id'));
$selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
?>
<div class="page-head">
<h1><?= $isEdit ? '编辑观影记录' : '记一场电影' ?></h1>
<span class="sub"><?= $isEdit ? e($m['title']) : '片名必填,其他都可以以后再补' ?></span>
<div class="actions">
<?php /* 表单页没有返回按钮(提交/取消在表单底部),返回放前面、设置贴最右。 */ ?>
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('movies/show', ['id' => (int)$m['id']]) : url('movies')) ?>">
<?= $isEdit ? '返回详情' : '返回观影' ?>
</a>
<?php $setCurrent = 'form'; $setCanWrite = \App\Auth::canWrite(); require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<form method="post" enctype="multipart/form-data"
action="<?= e(url($isEdit ? 'movies/update' : 'movies/create')) ?>">
<?= csrf_field() ?>
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<?php endif; ?>
<div class="card card-pad">
<div class="form-grid">
<div class="field full">
<label for="title">片名 *</label>
<div class="db-title-row">
<input class="input" id="title" name="title" value="<?= e(old('title', $m['title'] ?? '')) ?>"
required autocomplete="off">
<button type="button" class="btn btn-ghost btn-sm" id="dbFetch"
title="按片名从豆瓣抓取片名、简介、海报等">📡 自动获取</button>
</div>
<?php /* v10 豆瓣自动补充:输入片名出下拉候选,点「自动获取」直接抓。
抓到的元数据整体进隐藏 douban_meta(JSON),海报走服务端落盘的 poster_key。 */ ?>
<div class="db-suggest" id="dbSuggest" hidden></div>
<input type="hidden" name="douban_meta" id="dbMeta" value="">
<input type="hidden" name="poster_key" id="dbPosterKey" value="">
<div class="db-poster-preview" id="dbPosterWrap" hidden>
<img id="dbPosterImg" alt="海报预览">
<span class="tip">海报已从豆瓣取回,保存后生效。</span>
</div>
<span class="tip">片名可从豆瓣自动补充:输入后从下拉里选,或点「自动获取」。</span>
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<div class="field full">
<label <?= $isEdit ? '' : 'for="starts_at_date"' ?>>开始时间(精确到秒)</label>
<?php $dtName = 'starts_at'; $dtValue = $dtStart; $dtId = 'starts_at_date';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
</div>
<div class="field full">
<label>结束时间(散场,可不填)</label>
<?php $dtName = 'ends_at'; $dtValue = $dtEnd; $dtId = '';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
</div>
<div class="field full">
<label for="cinema_id">影院</label>
<div class="cinema-select-row">
<select class="select" id="cinema_id" name="cinema_id">
<option value="0">不记录影院</option>
<?php foreach ($cinemas as $c): ?>
<option value="<?= (int)$c['id'] ?>" <?= $selCinema === (int)$c['id'] ? 'selected' : '' ?>>
<?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?>
</option>
<?php endforeach; ?>
</select>
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/cinemas')) ?>">管理影院</a>
</div>
<span class="tip">想记一家还没录过的影院?先去「管理影院」里添加,回来这里就能选。</span>
</div>
<div class="field full">
<label>同行的人</label>
<?php /* v10:名册人多时 checkbox 网格会拉得很长 —— 改为「chips + 弹窗选择」。
attendees[] 的 checkbox 仍保留(在弹窗列表里,选中即提交),无 JS 时
借 noscript 样式把弹窗平铺成普通勾选列表,提交语义完全不变。
v11:chips 与弹窗列表都带上头像($p 来自 Person::roster,含 p.avatar)。 */ ?>
<?php if ($people): ?>
<div class="att-chips" id="attChips">
<?php foreach ($people as $p): ?>
<?php if (in_array((int)$p['id'], $selAttendees, true)): ?>
<span class="att-chip" data-id="<?= (int)$p['id'] ?>"><img src="<?= e(avatar_url($p['avatar'] ?? null)) ?>" alt=""><span class="att-name"><?= e($p['shown'] ?? $p['name']) ?></span><button type="button" class="att-x" data-id="<?= (int)$p['id'] ?>" title="移除">×</button></span>
<?php endif; ?>
<?php endforeach; ?>
</div>
<button type="button" class="btn btn-ghost btn-sm att-open-btn" id="attOpen">+ 添加观影人</button>
<div class="att-modal" id="attModal" hidden>
<div class="att-modal-box">
<div class="att-modal-head">
<b>选择观影人</b>
<button type="button" class="att-close" id="attClose" title="关闭">×</button>
</div>
<input class="input" id="attSearch" type="text"
placeholder="输入名字或拼音首字母,如 zy 找「张三」" autocomplete="off">
<div class="att-list" id="attList">
<?php foreach ($people as $p): ?>
<label class="att-item"
data-name="<?= e((string)$p['name']) ?>"
data-shown="<?= e((string)($p['shown'] ?? '')) ?>">
<input type="checkbox" name="attendees[]" value="<?= (int)$p['id'] ?>"
<?= in_array((int)$p['id'], $selAttendees, true) ? 'checked' : '' ?>>
<img class="att-av" src="<?= e(avatar_url($p['avatar'] ?? null)) ?>" alt="" loading="lazy">
<span class="att-name"><?= e($p['shown'] ?? $p['name']) ?></span>
<span class="att-initials" hidden></span>
</label>
<?php endforeach; ?>
<div class="att-empty" hidden>没有匹配的人。</div>
</div>
</div>
</div>
<noscript><style>.att-open-btn { display: none; } .att-modal { position: static; display: block; background: none; padding: 0; } .att-modal-box { box-shadow: none; border: 1px solid var(--line); max-height: none; }</style></noscript>
<?php else: ?>
<span class="tip">名册里还没有人,先把家人加进名册再来记。</span>
<?php endif; ?>
</div>
<div class="field full">
<label for="info">简介</label>
<textarea class="input" id="info" name="info" rows="3"
placeholder="剧情简介(可选)"><?= e(old('info', $m['info'] ?? '')) ?></textarea>
</div>
<div class="field full">
<label for="note">备注</label>
<textarea class="input" id="note" name="note" rows="2"><?= e(old('note', $m['note'] ?? '')) ?></textarea>
</div>
<div class="field">
<label for="poster">海报</label>
<input class="input" id="poster" name="poster" type="file" accept="image/*">
<?php if ($isEdit && !empty($m['poster_key'])): ?>
<span class="tip">已有海报,不选新图就保留原图。</span>
<?php endif; ?>
</div>
<div class="field">
<label for="ticket">票根</label>
<input class="input" id="ticket" name="ticket" type="file" accept="image/*">
<?php if ($isEdit && !empty($m['ticket_key'])): ?>
<span class="tip">已有票根,不选新图就保留原图。</span>
<?php endif; ?>
</div>
<?php if (!$isEdit): ?>
<div class="field full">
<label for="photo">现场照片(可多选)</label>
<input class="input" id="photo" name="photo[]" type="file" accept="image/*" multiple>
<span class="tip">之后也可以在详情页里逐张补传。</span>
</div>
<?php endif; ?>
</div>
<div class="btn-row">
<button type="submit" class="btn"><?= $isEdit ? '保存' : '记下这场电影' ?></button>
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('movies/show', ['id' => (int)$m['id']]) : url('movies')) ?>">取消</a>
</div>
</div>
</form>
<?php if ($people): ?>
<!-- 观影人选择弹窗:chips 同步 + 拼音首字母联想(Intl pinyin 排序 + 23 锚字二分,零依赖) -->
<script>
(function () {
var modal = document.getElementById('attModal');
if (!modal) return;
var openBtn = document.getElementById('attOpen');
var closeBtn = document.getElementById('attClose');
var search = document.getElementById('attSearch');
var list = document.getElementById('attList');
var chips = document.getElementById('attChips');
var emptyTip = list.querySelector('.att-empty');
/* ---- 汉字 → 拼音首字母:锚字表 a..z(23 个,无 i/u/v 开头字),
用拼音 collation 做二分定位;不支持的浏览器返回 null(只按名字搜) ---- */
var initialsOf = (function () {
var anchors = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝';
var letters = 'abcdefghjklmnopqrstwxyz';
var coll;
try { coll = new Intl.Collator('zh-Hans-CN-u-co-pinyin'); } catch (e) { return null; }
if (!coll || coll.compare('阿', '匝') >= 0) return null; // collation 没生效
return function (s) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s[i];
if (/[a-zA-Z]/.test(ch)) { out += ch.toUpperCase(); continue; }
if (ch.charCodeAt(0) < 0x2E80) continue; // 非 CJK:不产字母
var lo = 0, hi = anchors.length - 1, best = -1;
while (lo <= hi) {
var mid = (lo + hi) >> 1;
if (coll.compare(ch, anchors[mid]) >= 0) { best = mid; lo = mid + 1; }
else { hi = mid - 1; }
}
if (best >= 0) out += letters[best];
}
return out;
};
})();
/* 预计算每个人的首字母串,挂在 data-initials 上 */
Array.prototype.forEach.call(list.querySelectorAll('.att-item'), function (el) {
var s = (el.getAttribute('data-name') || '') + (el.getAttribute('data-shown') || '');
var ini = initialsOf ? initialsOf(s) : '';
el.setAttribute('data-initials', ini.toLowerCase());
var badge = el.querySelector('.att-initials');
if (badge && ini) { badge.textContent = ini; badge.hidden = false; }
});
function syncChips() {
if (!chips) return;
chips.innerHTML = '';
Array.prototype.forEach.call(list.querySelectorAll('.att-item input:checked'), function (box) {
var item = box.closest('.att-item');
// ⚠️ 必须点名取 .att-name:v11 起 .att-item 里第一个元素是头像 <img>,
// 早先靠「取第一个 span 当名字」碰巧成立,现在第一个 span 是隐藏的
// 首字母占位,选完人 chip 上就没名字了。
var nameEl = item.querySelector('.att-name');
var label = nameEl ? nameEl.textContent : box.value;
var av = item.querySelector('.att-av');
var id = box.value;
var chip = document.createElement('span');
chip.className = 'att-chip';
chip.setAttribute('data-id', id);
if (av) {
var im = document.createElement('img');
im.src = av.getAttribute('src'); // 服务端已 e() 过,这里原样搬
im.alt = '';
chip.appendChild(im);
}
var nm = document.createElement('span');
nm.className = 'att-name';
nm.textContent = label;
chip.appendChild(nm);
var x = document.createElement('button');
x.type = 'button'; x.className = 'att-x'; x.textContent = '×';
x.title = '移除'; x.setAttribute('data-id', id);
chip.appendChild(x);
chips.appendChild(chip);
});
}
chips.addEventListener('click', function (e) {
var x = e.target.closest ? e.target.closest('.att-x') : null;
if (!x) return;
var id = x.getAttribute('data-id');
Array.prototype.forEach.call(list.querySelectorAll('.att-item input'), function (box) {
if (box.value === id) box.checked = false;
});
syncChips();
});
function applyFilter() {
var q = (search.value || '').trim().toLowerCase();
var shown = 0;
Array.prototype.forEach.call(list.querySelectorAll('.att-item'), function (el) {
var hit = !q ||
(el.getAttribute('data-name') || '').toLowerCase().indexOf(q) >= 0 ||
(el.getAttribute('data-shown') || '').toLowerCase().indexOf(q) >= 0 ||
(el.getAttribute('data-initials') || '').indexOf(q) >= 0;
el.hidden = !hit;
if (hit) shown++;
});
emptyTip.hidden = shown > 0;
}
function openModal() { modal.hidden = false; search.value = ''; applyFilter(); syncChips(); search.focus(); }
function closeModal() { modal.hidden = true; }
openBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function (e) { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modal.hidden) closeModal();
});
search.addEventListener('input', applyFilter);
/* 选一个 = 勾上 + 出 chips + 关弹窗(再添加就再点一次「+ 添加观影人」);
在弹窗里取消勾选只同步 chips,不关弹窗 */
list.addEventListener('change', function (e) {
if (!e.target || e.target.tagName !== 'INPUT') return;
syncChips();
if (e.target.checked) closeModal();
});
})();
</script>
<!-- 豆瓣自动补充:下拉联想(320ms 防抖 + 中文输入法组字期不请求)+ 一键抓取 -->
<script>
(function () {
var DB_URL = <?= json_encode(url('movies/douban')) ?>;
var titleEl = document.getElementById('title');
var box = document.getElementById('dbSuggest');
var fetchBtn = document.getElementById('dbFetch');
var metaEl = document.getElementById('dbMeta');
var posterKeyEl = document.getElementById('dbPosterKey');
var posterWrap = document.getElementById('dbPosterWrap');
var posterImg = document.getElementById('dbPosterImg');
var infoEl = document.getElementById('info');
if (!titleEl || !box || !fetchBtn || !metaEl) return;
var lastPosterKey = ''; // 本次会话里最近一次抓到的海报键(下次抓取时回传清理)
var timer = null;
var composing = false;
var seq = 0; // 丢弃过期响应
titleEl.addEventListener('compositionstart', function () { composing = true; });
titleEl.addEventListener('compositionend', function () { composing = false; schedule(); });
titleEl.addEventListener('input', function () {
if (composing) return; // 组字期间的拼音串没有意义,也不劫持回车
schedule();
});
document.addEventListener('click', function (e) {
if (!box.hidden && !box.contains(e.target) && e.target !== titleEl) hideBox();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') hideBox();
});
function schedule() {
clearTimeout(timer);
timer = setTimeout(suggest, 320);
}
function hideBox() { box.hidden = true; box.innerHTML = ''; }
function suggest() {
var q = titleEl.value.trim();
if (q === '') { hideBox(); return; }
var my = ++seq;
getJson(DB_URL + '&action=suggest&q=' + encodeURIComponent(q), function (data, err) {
if (my !== seq) return; // 过期响应丢弃
if (err) return; // 联想失败静默(正片抓取会给出明确报错)
renderItems(data && data.items ? data.items : []);
});
}
function renderItems(items) {
box.innerHTML = '';
if (!items.length) { hideBox(); return; }
items.forEach(function (it) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'db-item';
if (it.thumb) {
var im = document.createElement('img');
im.src = it.thumb; im.alt = ''; im.loading = 'lazy';
btn.appendChild(im);
}
var tx = document.createElement('span');
tx.className = 'db-item-tx';
var b = document.createElement('b');
// 候选文本来自豆瓣 —— 一律 textContent 插入,绝不拼 innerHTML(豆瓣侧内容可注入脚本)
b.textContent = it.title || '(无片名)';
tx.appendChild(b);
var bits = [];
if (it.sub_title && it.sub_title !== it.title) bits.push(it.sub_title);
if (it.year) bits.push(it.year);
if (it.kind) bits.push(it.kind);
if (bits.length) {
var sm = document.createElement('small');
sm.textContent = bits.join(' · ');
tx.appendChild(sm);
}
btn.appendChild(tx);
btn.addEventListener('click', function () { hideBox(); fetchById(it.id); });
box.appendChild(btn);
});
box.hidden = false;
}
function fetchById(id) {
doFetch(DB_URL + '&id=' + encodeURIComponent(id));
}
fetchBtn.addEventListener('click', function () {
var q = titleEl.value.trim();
if (q === '') { titleEl.focus(); return; }
doFetch(DB_URL + '&q=' + encodeURIComponent(q));
});
function doFetch(urlWithQ) {
if (lastPosterKey) urlWithQ += '&old_poster=' + encodeURIComponent(lastPosterKey);
fetchBtn.disabled = true;
var my = ++seq;
getJson(urlWithQ, function (data, err) {
fetchBtn.disabled = false;
if (my !== seq) return;
if (err || !data) { alert(err || '抓取失败,请稍后再试'); return; }
if (data.error) { alert(data.error); return; }
apply(data);
});
}
function apply(data) {
if (data.title) titleEl.value = data.title;
if (data.meta) {
metaEl.value = JSON.stringify(data.meta);
// 剧情简介填进「简介」,但只填空白的 —— 用户已经写了就不覆盖
if (infoEl && data.meta.plot && infoEl.value.trim() === '') infoEl.value = data.meta.plot;
}
if (data.poster_key) {
lastPosterKey = data.poster_key;
posterKeyEl.value = data.poster_key;
if (data.poster) {
posterImg.src = data.poster;
posterWrap.hidden = false;
}
}
}
function getJson(u, cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', u, true);
xhr.onload = function () {
var data = null, err = '';
try { data = JSON.parse(xhr.responseText); }
catch (e) { err = '返回内容异常'; }
if (xhr.status >= 400) err = (data && data.error) ? data.error : ('请求失败(' + xhr.status + ')');
cb(data, err);
};
xhr.onerror = function () { cb(null, '网络异常'); };
xhr.send();
}
})();
</script>
<?php endif; ?>