feat: 片名输入加下拉联想,不必把片名打全就能选片
输入片名时实时下拉候选(海报缩略图 + 片名 + 原名 + 年份 + 电影/剧集标注), 选中即自动填充影片信息,省去「打全片名再点按钮」这一步。 fetch_movie.php - 新增 action=suggest:只回候选列表,不取详情、不下载海报、不写任何文件, 可随输入安全地频繁调用;会过滤「影人」类条目、按豆瓣条目 id 去重、默认限 8 条。 - 新增 action=thumb:服务端代取豆瓣缩略图。豆瓣图片有防盗链(只有 Referer 为 豆瓣域名才返回 200,其它一律 403),浏览器直连必然被拒,只能由服务端带 Referer 代取;并加 Cache-Control 让浏览器缓存,重复展开不重复回源。 - 新增 ?id=豆瓣条目id:跳过搜索直接取详情。下拉选中后走这条路,既省一次搜索, 也避免「再按片名搜一次、结果选错条目」。 - 安全:新增 validateDoubanImageUrl() 严格白名单(只允许 https://img*.doubanio.com/view/photo/…),否则 thumb 就成了 SSRF 跳板; 接口地址的测试用覆盖项一并写进注释。 add.php - 片名输入框改为「输入即联想」:320ms 防抖、会话内结果缓存、用 AbortController 丢弃过期在途请求、↑↓ 选择 / Enter 确认 / Esc 关闭、点击外部关闭、 中文输入法组字期间不发请求也不劫持 Enter(此时 Enter 是选字)。 - 候选里的豆瓣文本一律转义后再插入,避免把对方内容当 HTML 执行。 - 「📡 自动获取」按钮保留:候选里没有想要的条目时仍可按回车或点按钮,由服务端 挑最匹配的条目;按钮与下拉选中复用同一套获取逻辑(runFetch)。 README.md - 接口文档改为四种模式(q / id / suggest / thumb),补上参数、响应样例、 防盗链原因与白名单说明;使用说明与安全注意事项同步更新。 验证 - fetch_movie_test.py 端到端 79 项全通过(新增 suggest / thumb / id 三组用例, 含「suggest 与 thumb 不写任何文件」「非白名单地址一律 400」)。 - storage_test.py 回归 196 项全通过。 - A/B 渲染比对 47 个响应:仅 add.php 的两个页面有差异,且差异全部落在下拉联想 相关代码上,其余响应逐字节一致。 - 已部署到线上,双 PHP 版本(7.4 / 8.1)真实抓取通过:suggest 返回候选、 thumb 代取回 27KB JPEG、非白名单地址被拒、id= 直连取详情成功。
This commit is contained in:
@@ -315,11 +315,64 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
/* ---- 片名下拉联想 ---- */
|
||||
.ac-wrap { flex: 1; position: relative; min-width: 0; }
|
||||
.ac-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
margin-top: 4px;
|
||||
padding: 4px;
|
||||
max-height: 328px;
|
||||
overflow-y: auto;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.ac-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ac-item:hover, .ac-item.active { background: rgba(127, 127, 127, 0.14); }
|
||||
.ac-thumb {
|
||||
flex: 0 0 auto;
|
||||
width: 38px;
|
||||
height: 54px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
.ac-meta { min-width: 0; }
|
||||
.ac-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ac-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ac-hint { padding: 8px; font-size: 13px; color: var(--text-secondary); }
|
||||
@media (max-width: 600px) {
|
||||
.form-card { padding: 20px 16px; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
.form-actions { flex-direction: column; }
|
||||
.form-actions .btn { width: 100%; text-align: center; }
|
||||
.ac-list { max-height: 240px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -337,11 +390,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="" enctype="multipart/form-data" id="addForm">
|
||||
<!-- 电影名称 + 自动获取按钮 -->
|
||||
<!-- 电影名称 + 下拉联想 + 自动获取按钮 -->
|
||||
<div class="form-group">
|
||||
<label>电影名称 <span class="hint">*必填</span></label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" name="title" id="movieTitle" value="<?= h($_POST['title'] ?? '') ?>" placeholder="请输入电影名称" required style="flex:1;">
|
||||
<label>电影名称 <span class="hint">*必填(输入几个字即可,从下拉里选)</span></label>
|
||||
<div style="display: flex; gap: 10px; align-items: flex-start;">
|
||||
<div class="ac-wrap">
|
||||
<input type="text" name="title" id="movieTitle"
|
||||
value="<?= h($_POST['title'] ?? '') ?>"
|
||||
placeholder="输入片名(中文即可),下方会给出候选"
|
||||
required autocomplete="off"
|
||||
role="combobox" aria-expanded="false"
|
||||
aria-controls="titleSuggest" aria-autocomplete="list">
|
||||
<div id="titleSuggest" class="ac-list" role="listbox" hidden></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="fetchBtn" style="white-space:nowrap;">📡 自动获取</button>
|
||||
</div>
|
||||
<div id="fetchStatus" style="font-size:13px;color:var(--text-secondary);margin-top:4px;"></div>
|
||||
@@ -580,39 +641,48 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 豆瓣自动获取 ----------
|
||||
document.getElementById('fetchBtn').addEventListener('click', function() {
|
||||
const title = document.getElementById('movieTitle').value.trim();
|
||||
const status = document.getElementById('fetchStatus');
|
||||
const btn = this;
|
||||
// ---------- 豆瓣自动获取(「按钮」与「下拉选中」共用同一套逻辑)----------
|
||||
const movieTitleEl = document.getElementById('movieTitle');
|
||||
const movieInfoEl = document.getElementById('movieInfo');
|
||||
const fetchBtnEl = document.getElementById('fetchBtn');
|
||||
const fetchStatusEl = document.getElementById('fetchStatus');
|
||||
const fetchedKeyEl = document.getElementById('fetchedPosterKey');
|
||||
const posterPreviewEl = document.getElementById('posterPreview');
|
||||
const posterWrapEl = document.getElementById('posterPreviewWrap');
|
||||
const posterHintEl = document.getElementById('posterPreviewHint');
|
||||
|
||||
if (!title) {
|
||||
status.style.color = '#e74c3c';
|
||||
status.textContent = '⚠️ 请先输入电影名称';
|
||||
return;
|
||||
}
|
||||
// 豆瓣返回的文本一律先转义再插入,避免把对方内容当 HTML 执行
|
||||
function acEsc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
status.style.color = 'var(--text-secondary)';
|
||||
status.textContent = '⏳ 正在获取…';
|
||||
function setFetchStatus(text, color) {
|
||||
fetchStatusEl.style.color = color || 'var(--text-secondary)';
|
||||
fetchStatusEl.textContent = text;
|
||||
}
|
||||
|
||||
// query 形如 'q=片名' 或 'id=豆瓣条目id'
|
||||
function runFetch(query) {
|
||||
fetchBtnEl.disabled = true;
|
||||
setFetchStatus('⏳ 正在获取…');
|
||||
|
||||
// 上一次抓取但仍未采用的海报键,交给服务端清理,避免重复点击堆积文件
|
||||
const oldKey = document.getElementById('fetchedPosterKey').value;
|
||||
let url = 'fetch_movie.php?q=' + encodeURIComponent(title);
|
||||
let url = 'fetch_movie.php?' + query;
|
||||
const oldKey = fetchedKeyEl.value;
|
||||
if (oldKey) url += '&old_poster=' + encodeURIComponent(oldKey);
|
||||
|
||||
fetch(url)
|
||||
return fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
status.style.color = '#e74c3c';
|
||||
status.textContent = '❌ ' + data.error;
|
||||
btn.disabled = false;
|
||||
setFetchStatus('❌ ' + data.error, '#e74c3c');
|
||||
return;
|
||||
}
|
||||
|
||||
// 片名以豆瓣的正名称为准(自动补全/纠正)
|
||||
if (data.title) document.getElementById('movieTitle').value = data.title;
|
||||
if (data.title) movieTitleEl.value = data.title;
|
||||
|
||||
// 组装电影信息文本
|
||||
let infoText = '';
|
||||
@@ -625,44 +695,195 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (data.rating) infoText += '⭐ 豆瓣评分:' + data.rating + '\n';
|
||||
if (data.alias) infoText += '🔖 又名:' + data.alias + '\n';
|
||||
if (data.plot) infoText += '\n📝 ' + data.plot;
|
||||
|
||||
document.getElementById('movieInfo').value = infoText;
|
||||
movieInfoEl.value = infoText;
|
||||
|
||||
// 海报:已由服务端下载入库。这里记录键值(提交时写入 poster_path)并预览。
|
||||
const keyInput = document.getElementById('fetchedPosterKey');
|
||||
const preview = document.getElementById('posterPreview');
|
||||
const wrap = document.getElementById('posterPreviewWrap');
|
||||
const hint = document.getElementById('posterPreviewHint');
|
||||
|
||||
if (data.poster && data.poster_key) {
|
||||
keyInput.value = data.poster_key;
|
||||
preview.src = data.poster;
|
||||
hint.textContent = '海报已自动保存,提交时一并写入(若手动上传图片,以上传的为准)';
|
||||
wrap.style.display = 'block';
|
||||
status.textContent = '✅ 已获取信息与海报';
|
||||
fetchedKeyEl.value = data.poster_key;
|
||||
posterPreviewEl.src = data.poster;
|
||||
posterHintEl.textContent = '海报已自动保存,提交时一并写入(若手动上传图片,以上传的为准)';
|
||||
posterWrapEl.style.display = 'block';
|
||||
setFetchStatus('✅ 已获取信息与海报', '#27ae60');
|
||||
} else {
|
||||
keyInput.value = '';
|
||||
wrap.style.display = 'none';
|
||||
status.textContent = '✅ 已获取基本信息(该片暂无海报)';
|
||||
fetchedKeyEl.value = '';
|
||||
posterWrapEl.style.display = 'none';
|
||||
setFetchStatus('✅ 已获取基本信息(该片暂无海报)', '#27ae60');
|
||||
}
|
||||
status.style.color = '#27ae60';
|
||||
btn.disabled = false;
|
||||
})
|
||||
.catch(err => {
|
||||
status.style.color = '#e74c3c';
|
||||
status.textContent = '❌ 网络错误,请稍后重试';
|
||||
btn.disabled = false;
|
||||
setFetchStatus('❌ 网络错误,请稍后重试', '#e74c3c');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(function () {
|
||||
fetchBtnEl.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
fetchBtnEl.addEventListener('click', function () {
|
||||
const title = movieTitleEl.value.trim();
|
||||
if (!title) {
|
||||
setFetchStatus('⚠️ 请先输入电影名称', '#e74c3c');
|
||||
return;
|
||||
}
|
||||
runFetch('q=' + encodeURIComponent(title));
|
||||
});
|
||||
|
||||
// 回车触发获取
|
||||
document.getElementById('movieTitle').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
document.getElementById('fetchBtn').click();
|
||||
// ---------- 片名下拉联想 ----------
|
||||
// 输入几个字 → 服务端只做一次搜索(不取详情、不下载海报)→ 下拉展示候选。
|
||||
// 选中后按豆瓣条目 id 直达详情,省一次搜索,也不会「重新搜一次选错条目」。
|
||||
(function () {
|
||||
const listEl = document.getElementById('titleSuggest');
|
||||
if (!listEl) return;
|
||||
|
||||
const CACHE = new Map(); // 关键词 -> items,同一会话内不重复请求
|
||||
const DEBOUNCE = 320;
|
||||
let timer = null;
|
||||
let ctrl = null; // 用于丢弃过期的在途请求
|
||||
let items = [];
|
||||
let active = -1;
|
||||
let composing = false; // 中文输入法组字中
|
||||
|
||||
function closeList() {
|
||||
listEl.hidden = true;
|
||||
listEl.innerHTML = '';
|
||||
movieTitleEl.setAttribute('aria-expanded', 'false');
|
||||
items = [];
|
||||
active = -1;
|
||||
}
|
||||
});
|
||||
|
||||
function render(rows, hintText) {
|
||||
if (!rows.length) {
|
||||
if (!hintText) { closeList(); return; }
|
||||
listEl.innerHTML = '<div class="ac-hint">' + acEsc(hintText) + '</div>';
|
||||
listEl.hidden = false;
|
||||
movieTitleEl.setAttribute('aria-expanded', 'true');
|
||||
items = [];
|
||||
active = -1;
|
||||
return;
|
||||
}
|
||||
items = rows;
|
||||
active = -1;
|
||||
listEl.innerHTML = rows.map(function (r, i) {
|
||||
const thumb = r.thumb
|
||||
? '<img class="ac-thumb" src="' + acEsc(r.thumb) + '" alt="" loading="lazy">'
|
||||
: '<div class="ac-thumb"></div>';
|
||||
// 副标题:原名(与正名不同才显示)、年份、电影/剧集、集数
|
||||
const sub = [
|
||||
(r.sub_title && r.sub_title !== r.title) ? r.sub_title : '',
|
||||
r.year,
|
||||
r.kind,
|
||||
(parseInt(r.episode, 10) > 1) ? (r.episode + ' 集') : ''
|
||||
].filter(Boolean).join(' · ');
|
||||
return '<div class="ac-item" role="option" data-i="' + i + '">' + thumb +
|
||||
'<div class="ac-meta">' +
|
||||
'<div class="ac-title">' + acEsc(r.title) + '</div>' +
|
||||
'<div class="ac-sub">' + acEsc(sub) + '</div>' +
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
listEl.hidden = false;
|
||||
movieTitleEl.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
function highlight() {
|
||||
Array.prototype.forEach.call(listEl.querySelectorAll('.ac-item'), function (el, i) {
|
||||
el.classList.toggle('active', i === active);
|
||||
});
|
||||
const cur = listEl.querySelector('.ac-item.active');
|
||||
if (cur) cur.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function move(delta) {
|
||||
if (!items.length) return;
|
||||
active = (active + delta + items.length) % items.length;
|
||||
highlight();
|
||||
}
|
||||
|
||||
function choose(i) {
|
||||
const it = items[i];
|
||||
if (!it) return;
|
||||
closeList();
|
||||
movieTitleEl.value = it.title;
|
||||
runFetch('id=' + encodeURIComponent(it.id));
|
||||
}
|
||||
|
||||
function request(kw) {
|
||||
if (CACHE.has(kw)) {
|
||||
render(CACHE.get(kw));
|
||||
return;
|
||||
}
|
||||
if (ctrl) ctrl.abort(); // 丢掉上一次在途请求
|
||||
ctrl = new AbortController();
|
||||
const mine = ctrl;
|
||||
|
||||
render([], '⏳ 搜索中…');
|
||||
|
||||
fetch('fetch_movie.php?action=suggest&q=' + encodeURIComponent(kw),
|
||||
{ signal: mine.signal })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (movieTitleEl.value.trim() !== kw) return; // 期间又输入了,结果作废
|
||||
const rows = (data && data.items) ? data.items : [];
|
||||
CACHE.set(kw, rows);
|
||||
render(rows, '没有找到相关影视,试试补充更完整的片名');
|
||||
})
|
||||
.catch(err => {
|
||||
if (err && err.name === 'AbortError') return;
|
||||
render([], '联想暂时不可用,可直接点「📡 自动获取」');
|
||||
});
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
const kw = movieTitleEl.value.trim();
|
||||
clearTimeout(timer);
|
||||
if (!kw) { closeList(); return; }
|
||||
timer = setTimeout(function () { request(kw); }, DEBOUNCE);
|
||||
}
|
||||
|
||||
// 中文输入法:组字期间不请求(拼音串没意义),也不劫持 Enter(此时 Enter 是选字)
|
||||
movieTitleEl.addEventListener('compositionstart', function () { composing = true; });
|
||||
movieTitleEl.addEventListener('compositionend', function () {
|
||||
composing = false;
|
||||
schedule();
|
||||
});
|
||||
movieTitleEl.addEventListener('input', function () {
|
||||
if (!composing) schedule();
|
||||
});
|
||||
|
||||
movieTitleEl.addEventListener('keydown', function (e) {
|
||||
if (e.isComposing || composing) return; // 交给输入法处理
|
||||
|
||||
if (!listEl.hidden && items.length) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); move(1); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); return; }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
choose(active >= 0 ? active : 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') { closeList(); return; }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
closeList();
|
||||
fetchBtnEl.click(); // 无候选时,Enter 等价于点「自动获取」
|
||||
}
|
||||
});
|
||||
|
||||
// 用 mousedown + preventDefault:否则会先失焦关掉列表,导致点不中
|
||||
listEl.addEventListener('mousedown', function (e) {
|
||||
const el = e.target.closest ? e.target.closest('.ac-item') : null;
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
choose(parseInt(el.dataset.i, 10));
|
||||
});
|
||||
|
||||
// 点到别处则关闭
|
||||
document.addEventListener('click', function (e) {
|
||||
if (listEl.hidden) return;
|
||||
if (e.target === movieTitleEl || listEl.contains(e.target)) return;
|
||||
closeList();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user