Files
FamilyBook/templates/movies/form.php
T
laoyang b2ca4f336e v24 第 10 批:名册真性能(根因是每卡内联表单)等九条
- #6 名册线上真实数据(1184 人)卡顿:根因是**每张卡片内联一整份分组弹层**——
  全页 2369 个 form / 27234 个 input / 22496 个 radio / 98100 DOM 节点,
  密码管理器(Bitwarden 等)逐个扫描 form 把主线程占死(用户报的就是这个冲突)。
  改为卡片只带 data 属性 + 全页**唯一一份**共享浮层 #rosterMenus。
  实测:form 2369→3、input 27234→25、DOM 98100→17659、解压 7694→2306 KB、
  长任务 1/52ms → 0。新增哨兵 _fb_formbloat_check.py(含反向验证)。
- #5 日视图翻页失效:day_columns() 无条件吸到周一,天视图只有 1 列 → 永远显示那周周一。
  改为仅 span>1 时吸附(周视图行为不变)。
- #2 财务「欠款」类别(负债);并修一个真 bug:负债填负数会让净资产**反向变大**。
  写入口 normalizeAmount 归一化 + summary/timeline 侧 abs 防御 + Schema v17 纠正老数据。
- #3 账目行「盘点」「修改」两个并列按钮(各自就地弹面板);图移到账目上方,
  总金额图跨整行 + 每个账目一条线 + 类别筛选条;charts() 改为覆盖全部账目。
- #4 日程新增「农历」「节假日」两本不可删除只读日历(app/Holiday.php):
  两级自动更新 —— 内置规则(公历固定 + 清明节气 + 农历节日)保证断网也有;
  官方 JSON 镜像自动抓取补齐调休。抓取**不在渲染路径联网**(响应后 fastcgi_finish_request
  + 频率闸门),缓存落 storage/cache。仅派生本不可写日程。
- #6 日程页设置按钮前加「跳到某一天」(选中即跳)。
- #7 起止范围改用一个 Semi dateTimeRange 控件(观影/旅程/节点/日程共 4 处);
  新增 window.fbSetDT / fbUpgradeRange;修范围重挂(React 非受控组件须 unmount 再 createRoot)
  与范围里 required 未摘除导致表单无法提交两个真实问题。

验证:e2e 786/0、batch10 44/0、batch9 38/0、batch_e 91/0、upgrade 126/0、picker 28/0、
semi_integrate 27/0、formbloat 5/0(+反向 3 红)、mobile 688/0、interact 97/0、drag 5/0、geom 5/5;
线上自检 19/19 + 线上真实账号巡检 28/0(含 Firefox 实测)。
2026-10-04 10:46:50 +08:00

331 lines
15 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 /* 文案一律「返回观影」(用户 2026-10-01 要求:观影模块里不再出现
「返回观影记录」「返回列表」「返回详情」三种叫法)。
落地页仍按来路走:从详情页点「编辑」进来的,回去还是那条记录的详情页。 */ ?>
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('movies/show', ['id' => (int)$m['id']]) : url('movies')) ?>">
返回观影
</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>
<?php /* 用户 2026-10-04 第 7 条:起止是一个**范围**,应该由日期插件的
范围选择方式一次选完,而不是并列两个控件(不好看、也多一步操作)。
服务端仍然收 starts_at_* / ends_at_* 两组字段(契约不变),
只是升级后由**一个** Semi dateTimeRange 统一填两端(见 app.js)。 */ ?>
<div class="field full dt-range">
<label <?= $isEdit ? '' : 'for="starts_at_date"' ?>>开始 / 结束时间(精确到秒)</label>
<?php $dtName = 'starts_at'; $dtValue = $dtStart; $dtId = 'starts_at_date';
$dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
<?php $dtName = 'ends_at'; $dtValue = $dtEnd; $dtId = '';
$dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
<span class="tip">散场时间可不填。</span>
</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 /* v24 第 11 条:换成全局人员选择器(templates/_person_picker.php)。
原来这套是观影页私有的 .att-*,它的「过滤」其实一直是坏的 ——
.att-item 自己写了 display:flex,压过了浏览器的 [hidden]{display:none},
JS 里 el.hidden = true 根本没生效,搜什么都还是一整列。 */ ?>
<?php if ($people): ?>
<?php
$ppName = 'attendees[]';
$ppPeople = $people;
$ppSelected = $selAttendees;
$ppMode = 'multi';
$ppOpenBtn = '+ 添加观影人';
$ppTitle = '选择观影人';
$ppTip = '';
require __DIR__ . '/../_person_picker.php';
?>
<?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="6"
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>
<!-- 豆瓣自动补充:下拉联想(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);
}
/* 简介:用服务端拼好的 summary(**含导演/主演/年份/类型/片长/国家/评分 + 剧情原文**),
而不是只塞剧情那一小段。用户 2026-10-03:「从豆瓣获取的信息不够,
应该把导演、主要演员,上映年份等主要信息,也都放在简介中。」
⚠️ 仍然**只填空白的**:用户已经写过的内容不能被抓取覆盖。
⚠️ 而且要把"有东西但没写进简介"这件事说清楚 —— 以前只塞 plot,
遇到豆瓣没有 plot 的电影就**什么都不填而且没有提示**,用户会以为抓取坏了。
现在:抓到了但简介非空 → 明确提示;什么都没抓到 → 也明确说一声。 */
if (infoEl) {
var summary = (data.summary || '').trim();
if (summary !== '') {
if (infoEl.value.trim() === '') {
infoEl.value = summary;
} else {
infoEl.setAttribute('data-db-summary', summary);
alert('豆瓣信息已抓取。\n\n「简介」里已经有你写的内容,我没有覆盖它。\n'
+ '如果想用豆瓣的简介(含导演/主演/年份等),请先清空「简介」再点一次「自动获取」。');
}
} else if ((data.meta && (data.meta.plot || data.meta.director)) || data.title) {
alert('豆瓣信息已抓取(片名/海报/元数据),但这部电影豆瓣没有可用的简介文本。');
}
}
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>