Files
FamilyBook/templates/movies/form.php
T
laoyang 2fda455a59 v36 第 36 批:时间控件改「日期 + 时/分下拉」(修掉 v35 真没生效的可见性 bug)+ 详情页去掉补照片
用户原话:
  ①「观影模块,开始时间和结束时间是空白的,无法选择,可以调整为,前面是日期,
     后面是时和分,只记录到时和分就行了。时和分,是下拉的。」
  ②「观影详情页面,不需要设置补充照片的功能,都放在编辑页面就行了。」

① 时间控件:**v35 的修复根本没生效**(我的锅),而且探针连绿两轮没抓到
  真因:upgradeRange 里只给范围**组**加了 `.is-native-range`,
  **没给两个端点 `.dt-input` 加 `.is-native`** ⇒ CSS
  `html.js .dt-input:not(.is-semi) .dt-native { visibility: hidden }`(特异性 0,3,0)
  照样命中 ⇒ 控件 **visibility:hidden**:**占着 48px 高度、完全不可见、点不到**
  (用户看到的"一片空白")。
  🔴 探针为什么没抓到:它用 `offsetParent !== null` 判"可见" ——
     而 **visibility:hidden 的元素 offsetParent 仍然非 null**,于是连绿两轮。
     ⇒ 判可见改成 computed visibility + display + getBoundingClientRect 有宽高
       (_fb_b34_dtrange.py 与 _fb_geom_interact.py 都已改,后者直接用 is_visible())。

  修法(比 v35 更笨但更稳):**不造新控件、不加 class 猜 CSS**,直接
    ① 端点加 `.is-native`(压掉藏原生那条规则);
    ② 删掉"秒"那一档(连它前面的冒号)—— 用户"只记录到时和分";
    ③ **inline 强制 display/visibility**(双保险;v35 就是被历史 CSS 规则翻掉的)。
  CSS 侧关键几条加 `!important`,并删掉 v35 遗留的 `.dt-merged` / `.dt-time-input`
  规则(免得后人误以为还有"收起来的下拉")。
  形态 = 「日期框 + 时下拉 + 分下拉」,提交契约仍是 *_date/_hour/_minute
  (秒栏不再有,服务端 read_dt_input 对缺失 *_second 按 00)。

② 详情页移除"补一张照片"表单(form.photo-add),留一句"要加照片点上方编辑"。
  ⚠️ 服务端 `movies/photo_add` 路由**保留**(老书签/直接 POST 仍可用),只是不再有入口。

验证(全部真机 + 反向验证):
  · _fb_b34_dtrange.py **37/0**:四个表单页断言「日期框可见 + **时**下拉可见 +
    **分**下拉可见 + **没有"秒"** + 只改时分也能提交」;
  · _fb_b34_dtrange_falsify.py **5/0**:注入"去掉 .is-native"(= v35 的真错)
    → **12 条断言变红** ⇒ 证明新判据真的能抓到那个 bug;
    ⚠️ 而"只撤 CSS !important"或"只撤 inline 强制"都 0 条变红(被别的兜底救回来)——
      做证伪必须挑"真正决定行为的那一层";
  · _fb_b35_movie_media.py **16/0**(⑥ 改为"详情页没有补照片入口");
  · e2e **1503/0**(详情页入口断言反过来)、几何交互 **96/0**(+4 条时分下拉);
  · 各批真机/反向全绿、部署静态断言空跑通过;
  · 线上 app.js / show.php 的 SHA256 与本地一致(v36 已上线)。
2026-10-09 22:28:13 +08:00

587 lines
29 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);
// 刚用「新建影院」弹框加完一家 → 回到这里要**自动选中**它(见 index.php 的 cinemas_save)。
// 优先级:老输入 > ?cinema=(刚新建的)> 记录里的值。
$selCinema = (int)old('cinema_id', (int)($_GET['cinema'] ?? 0) ?: ($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 条:起止是一个**范围**。用户 2026-10-09 第 2 次:
「前面是日期,后面是时和分,只记录到时和分就行了。时和分,是下拉的。」
⇒ 形态 = 日期框 + 时/分两个下拉(**没有秒**,见 app.js upgradeRange 的说明)。 */ ?>
<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>
<?php /* 用户 2026-10-04 第 11 批 #4(三件事):
① 「选择影院的时候,影院的颜色要出现在下拉列表中,用来显示不同的影院」
→ 原生 <option> **画不了色块**(浏览器只渲染纯文本),
所以这里换成自定义下拉:按钮 + 弹出列表,每行左侧一个影院色点。
真实值仍然提交给隐藏的 <input name="cinema_id">(表单契约不变)。
② 「管理影院按钮,改为新建影院,点击后弹框,添加新影院,不要跳转到管理影院的页面」
→ 弹框里放"地方/名称/颜色"三个字段(影院管理页那张表单的核心),
提交时带 back=movies/new,服务端存完回到本页并自动选中新影院。
③ 下拉/弹框的样式与全站统一(宣纸底色、品牌色描边)。 */ ?>
<div class="field full">
<label for="cinema_id">影院</label>
<div class="cinema-select-row">
<div class="cs" id="cinemaPicker" data-open="0">
<button type="button" class="cs-btn" id="csBtn" aria-haspopup="listbox" aria-expanded="false">
<span class="cs-dot" id="csDot"<?= $selCinema > 0 ? '' : ' hidden' ?>></span>
<span class="cs-text" id="csText">不记录影院</span>
<span class="cs-caret" aria-hidden="true">▾</span>
</button>
<div class="cs-menu" id="csMenu" hidden role="listbox">
<button type="button" class="cs-item" data-id="0" data-color="" role="option">
<span class="cs-dot is-none" aria-hidden="true"></span><span>不记录影院</span>
</button>
<?php foreach ($cinemas as $c): ?>
<button type="button" class="cs-item" role="option"
data-id="<?= (int)$c['id'] ?>"
data-color="<?= e((string)($c['color'] ?: '#888888')) ?>"
data-text="<?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?>">
<span class="cs-dot" style="background: <?= e((string)($c['color'] ?: '#888888')) ?>;"></span>
<span><?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?></span>
</button>
<?php endforeach; ?>
</div>
<input type="hidden" id="cinema_id" name="cinema_id" value="<?= (int)$selCinema ?>">
</div>
<button type="button" class="btn btn-ghost btn-sm" id="cinemaNewBtn">新建影院</button>
</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>
<?php
/**
* v24 第 20 批 第 1 条:编辑页把**已经存过的**海报 / 观影照片**显示出来**。
*
* 用户原话:「观影模块,编辑观影,已经保存的海报和票根、合影,都没带进来。」
* ⚠️ 原来这里只有两句灰字提示("已有海报,不选新图就保留原图。")——
* 也就是说**图上你根本看不见图**:想确认"当时选的是哪张"、或想换掉它,
* 都得先回到详情页看一眼。用户要的是"带进来",即**看得见**。
*
* 做法:能预览的(图片)给缩略图 + 删除;不可预览的给一个下载入口。
* ⚠️ 换图仍然靠"选新文件"(服务端换新图时会顺手删掉旧对象,见 index.php movies/update),
* 所以这里**不需要**"确认换这张"之类的中间态。
* ⚠️ 每一块的删除按钮都关联到一个**独立的表单** —— 它们必须在**外层表单之外**
* (form 嵌套会让外层表单提前关闭,"保存"键就失效了,第 20 批 第 3 条刚栽过)。
* 按钮留在原位、用 HTML5 的 `form="id"` 关联到外面那个表单。
*/
$mHasPoster = $isEdit && !empty($m['poster_key']);
$mHasTicket = $isEdit && !empty($m['ticket_key']);
$mPhotos = $isEdit ? (array)($m['photos'] ?? []) : [];
$mDelForms = []; // 收集起来,在表单**外面**统一渲染
?>
<div class="form-grid" style="margin-top:4px">
<?php /* ---- 观影照片(v35:不再分类,合并成一块)----
用户原话:「除了海报之外,票根、现场照片等等都不分类别,都只分一个类,
放在一块。…只有海报是单独的…其他的都放在一个模块就行,
然后所有照片都上传到一起,直接写"观影照片"就可以了。」
⚠️ 数据层本来就是平的(`event_photos` 只有 key/position,没有类别列),
票根以前是存 `movie_details.ticket_key` 的**单张**槽位。
⇒ 归并 = 把票根也当成 `event_photos` 里的一张,页面上只留"观影照片"一块。
⚠️ **旧的 ticket_key 仍然要显示**(历史数据不能丢):编辑页把已有票根
列进下面这块的网格里(它没有 `event_photos` 行 id,所以只能"看"、
不能用照片的删除按钮删;要换就直接上传新的)。 */ ?>
<div class="field">
<label for="sd-poster">海报</label>
<?php if ($mHasPoster): ?>
<?php $mDelForms['poster'] = 'movies/media_del'; ?>
<div class="mv-slot">
<a href="<?= e(media_url((string)$m['poster_key'])) ?>" data-prev="image"
data-src="<?= e(media_url((string)$m['poster_key'])) ?>"
data-dl="<?= e(media_url((string)$m['poster_key'], ['dl' => 1])) ?>"
data-name="海报">
<img src="<?= e(media_url((string)$m['poster_key'])) ?>" alt="海报">
</a>
<button class="btn btn-ghost btn-sm" type="submit" form="mv-del-poster"
onclick="return confirm('删掉现在这张海报?')">删除这张</button>
</div>
<?php endif; ?>
<?php /* v24 第 21 批 第 7 条:一个「点击或拖动」的框,取代原来的原生文件框。
换图仍然靠"选/拖一张新的"(服务端换新图时会顺手删掉旧对象)。 */
$sdName = 'poster'; $sdSlot = 'poster'; $sdAccept = 'image/*';
$sdMultiple = false;
$sdMsg = $mHasPoster ? '点击或拖动进行上传(换一张海报)' : '点击或拖动进行上传';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $mHasPoster ? '选/拖一张新的会自动替换旧的那张。' : '还没有海报。' ?></span>
</div>
</div>
<?php /* ---- 观影照片:票根 + 合影 + 其它相关图片,**全部放这一块** ---- */ ?>
<div class="field full">
<label for="sd-photo">观影照片</label>
<?php if ($mHasTicket || $mPhotos): ?>
<div class="photo-grid">
<?php if ($mHasTicket): /* 历史数据:旧票根(不在 event_photos 里,只展示) */ ?>
<?php $mDelForms['ticket'] = 'movies/media_del'; ?>
<figure class="photo-cell">
<a href="<?= e(media_url((string)$m['ticket_key'])) ?>" data-prev="image"
data-src="<?= e(media_url((string)$m['ticket_key'])) ?>"
data-dl="<?= e(media_url((string)$m['ticket_key'], ['dl' => 1])) ?>"
data-name="票根">
<img src="<?= e(media_url((string)$m['ticket_key'])) ?>" alt="票根">
</a>
<button class="btn btn-ghost btn-sm" type="submit" form="mv-del-ticket"
onclick="return confirm('删掉这张旧票根?')">删除</button>
</figure>
<?php endif; ?>
<?php foreach ($mPhotos as $mp): $mpId = (int)$mp['id']; ?>
<figure class="photo-cell">
<a href="<?= e(media_url((string)$mp['key'])) ?>" data-prev="image"
data-src="<?= e(media_url((string)$mp['key'])) ?>"
data-dl="<?= e(media_url((string)$mp['key'], ['dl' => 1])) ?>"
data-name="观影照片">
<img src="<?= e(media_url((string)$mp['key'])) ?>" alt="">
</a>
<button class="btn btn-ghost btn-sm" type="submit" form="mv-del-photo-<?= $mpId ?>"
onclick="return confirm('删掉这张照片?')">删除</button>
</figure>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php /* 观影照片(票根/合影/其它)都从这里进(可多选、可多次拖) */ ?>
<?php $sdName = 'photo[]'; $sdSlot = 'photo'; $sdAccept = 'image/*';
$sdMsg = '点击或拖动进行上传';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $isEdit ? '观影照片都可以放这里(可多选);删掉的在保存后生效。'
: '观影照片都可以放这里(可多选);之后也能在详情页里逐张补传。' ?></span>
</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 /* ---- 删除海报 / 票根 / 合影的**独立表单**(必须在外层表单之外)----
⚠️ 见本文件顶部关于 form 嵌套的说明:按钮用 `form="id"` 指过来,按钮位置不变。
⚠️ 关联表单要自带全部字段(csrf / 记录 id / 照片 id)—— 提交的是**它自己**的字段。 */ ?>
<?php if ($mHasPoster): ?>
<form id="mv-del-poster" method="post" action="<?= e(url('movies/media_del')) ?>" class="form-outside">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="slot" value="poster">
</form>
<?php endif; ?>
<?php if ($mHasTicket): ?>
<form id="mv-del-ticket" method="post" action="<?= e(url('movies/media_del')) ?>" class="form-outside">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="slot" value="ticket">
</form>
<?php endif; ?>
<?php foreach ($mPhotos as $mp): ?>
<form id="mv-del-photo-<?= (int)$mp['id'] ?>" method="post"
action="<?= e(url('movies/photo_del')) ?>" class="form-outside">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$mp['id'] ?>">
<?php /* 从编辑页删的 → 删完回编辑页(见 index.php movies/photo_del 的说明) */ ?>
<input type="hidden" name="back" value="edit">
</form>
<?php endforeach; ?>
<?php unset($mHasPoster, $mHasTicket, $mPhotos, $mDelForms, $mp, $mpId); ?>
<?php /* ---- 新建影院:弹框(不跳转,见上)---- */ ?>
<div class="cn-modal" id="cinemaNewModal" hidden>
<div class="cn-mask" data-cn-close></div>
<div class="cn-panel" role="dialog" aria-modal="true" aria-label="新建影院">
<div class="cn-head"><b>新建影院</b>
<button type="button" class="cn-x" data-cn-close aria-label="关闭">✕</button></div>
<form method="post" action="<?= e(url('movies/cinemas_save')) ?>">
<?= csrf_field() ?>
<?php /* back=movies/new 让服务端存完回到**本页**(而不是甩去影院管理页) */ ?>
<input type="hidden" name="back" value="movies/new">
<div class="cn-body">
<div class="field">
<label>地方(可不填)</label>
<input class="input" name="place" placeholder="如:青岛" maxlength="100">
</div>
<div class="field">
<label>影院名称 *</label>
<input class="input" name="name" required placeholder="如:万象城 CGV" maxlength="100">
</div>
<div class="field">
<label>主题颜色</label>
<div class="color-row">
<input type="color" name="color" class="color-picker cn-color" value="#A65C30">
<span class="color-hex cn-hex">#A65C30</span>
<span class="cn-presets">
<?php foreach (['#A63030','#A65C30','#A68930','#6BA630','#30A67A','#3078A6','#6B30A6','#A6305C'] as $pc): ?>
<button type="button" class="preset-color cn-preset" data-color="<?= $pc ?>"
style="background: <?= $pc ?>;" title="<?= $pc ?>"></button>
<?php endforeach; ?>
</span>
</div>
<span class="tip">这个颜色会出现在下拉、日历与观影列表上。</span>
</div>
</div>
<div class="cn-foot">
<button class="btn" type="submit">添加</button>
<button class="btn btn-ghost" type="button" data-cn-close>取消</button>
</div>
</form>
</div>
</div>
<!-- 豆瓣自动补充:下拉联想(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();
}
})();
/* ==================== 影院自定义下拉 + 新建影院弹框(第 11 批 #4)====================
① 下拉:原生 <option> 画不了色块,所以自己做一个"按钮 + 弹出列表"。
⚠️ 值仍然写进隐藏的 <input name="cinema_id">,服务端与既有测试都不用改。
② 新建影院:弹框里提交到 movies/cinemas_save(带 back=movies/new),
存完服务端会跳回本页并把新影院 id 放在 ?cinema= 上 → 模板已自动选中它。 */
(function () {
var picker = document.getElementById('cinemaPicker');
if (!picker) { return; }
var btn = document.getElementById('csBtn');
var menu = document.getElementById('csMenu');
var dot = document.getElementById('csDot');
var text = document.getElementById('csText');
var input = document.getElementById('cinema_id');
function close() { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); }
function openMenu() { menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); }
function apply(id, color, label) {
input.value = String(id);
text.textContent = label || '不记录影院';
if (color) {
dot.hidden = false;
dot.style.background = color;
dot.classList.remove('is-none');
} else {
dot.hidden = true;
}
}
// 初始状态:按当前值把按钮上的色点/文字同步过来
(function init() {
var cur = String(input.value || '0');
var items = menu.querySelectorAll('.cs-item');
for (var i = 0; i < items.length; i++) {
if (items[i].getAttribute('data-id') === cur) {
var lbl = items[i].querySelector('span:last-child');
apply(cur, items[i].getAttribute('data-color'), lbl ? lbl.textContent : '');
break;
}
}
})();
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (menu.hidden) { openMenu(); } else { close(); }
});
menu.addEventListener('click', function (e) {
var it = e.target.closest ? e.target.closest('.cs-item') : null;
if (!it) { return; }
e.stopPropagation();
var lbl2 = it.querySelector('span:last-child');
apply(it.getAttribute('data-id'), it.getAttribute('data-color'), lbl2 ? lbl2.textContent : '');
close();
});
// 点外面关(冒泡阶段,只关下拉本身)
document.addEventListener('click', function () { close(); });
// ---- 新建影院弹框 ----
var modal = document.getElementById('cinemaNewModal');
var nbtn = document.getElementById('cinemaNewBtn');
if (modal && nbtn) {
nbtn.addEventListener('click', function () {
modal.hidden = false;
var f = modal.querySelector('input[name="name"]');
if (f) { f.focus(); }
});
modal.addEventListener('click', function (e) {
if (e.target.closest && e.target.closest('[data-cn-close]')) { modal.hidden = true; }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { modal.hidden = true; }
});
// 颜色选择器与预设色(复刻影院管理页那套,只是缩在弹框里)
var cp = modal.querySelector('.cn-color');
var hx = modal.querySelector('.cn-hex');
if (cp && hx) {
cp.addEventListener('input', function () { hx.textContent = cp.value; });
}
modal.addEventListener('click', function (e) {
var pv = e.target.closest ? e.target.closest('.cn-preset') : null;
if (!pv || !cp) { return; }
cp.value = pv.getAttribute('data-color');
if (hx) { hx.textContent = cp.value; }
});
}
})();
</script>