Files
MovieLog_Server/add.php
T
laoyang 99c22d17bd 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= 直连取详情成功。
2026-09-22 07:36:05 +08:00

889 lines
37 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
// ============================================
// 文件: add.php (支持豆瓣自动获取 + 票根裁剪)
// ============================================
require_once 'config.php';
checkLogin();
$admin = getCurrentAdmin();
$cinemas = getCinemas();
// 获取所有观影人
$db = getDB();
$stmt = $db->query("SELECT * FROM persons ORDER BY name");
$all_persons = $stmt->fetchAll();
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$info = trim($_POST['info'] ?? '');
$watch_date = $_POST['watch_date'] ?? '';
$cinema_id = $_POST['cinema_id'] ?? '';
$viewer_selects = $_POST['viewer_select'] ?? [];
$viewer_news = $_POST['viewers_new'] ?? [];
// 票根裁剪数据
$ticket_crop = $_POST['ticket_crop'] ?? '';
// 电影院:选择已有影院、新建影院或留空
$cinema_id = resolveCinemaId(
$cinema_id,
trim($_POST['new_cinema_place'] ?? ''),
trim($_POST['new_cinema_name'] ?? ''),
trim($_POST['new_cinema_address'] ?? '')
);
if (empty($title)) {
$error = '请输入电影名称';
} elseif (empty($watch_date)) {
$error = '请选择观影日期';
} else {
// ----- 海报:优先手动上传,其次采用「自动获取」已抓取入库的海报 -----
$poster_path = uploadImageFile('poster', POSTER_DIR, 'uploads/posters/') ?? '';
if ($poster_path === '') {
// 前端把「自动获取」接口返回的 poster_key 放在隐藏域回传。
// 该键来自客户端,不可信任:需校验目录/文件名,并确认文件确实存在才采用。
$fetched_poster = trim($_POST['fetched_poster_key'] ?? '');
if (isValidStoredKey($fetched_poster, 'uploads/posters/') && storageExists($fetched_poster)) {
$poster_path = $fetched_poster;
}
}
// ----- 票根:裁剪结果优先,其次原始上传 -----
$ticket_path = '';
if (!empty($ticket_crop)) {
$new_path = saveBase64Image($ticket_crop, TICKET_DIR, 'ticket');
if ($new_path) {
$ticket_path = $new_path;
} else {
$error = '票根裁剪保存失败,请重试';
}
} else {
$ticket_path = uploadImageFile('ticket', TICKET_DIR, 'uploads/tickets/') ?? '';
}
// 没有错误才继续
if (empty($error)) {
// 插入电影
$stmt = $db->prepare("
INSERT INTO movies (title, info, poster_path, ticket_path, cinema_id, watch_date)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([$title, $info, $poster_path, $ticket_path, $cinema_id, $watch_date]);
$movie_id = $db->lastInsertId();
// ---- 观影照片(多张)----
uploadMultiplePhotos('photos', $movie_id);
// ---- 一起看的人 ----
linkViewers($movie_id, resolvePersonIds($viewer_selects, $viewer_news));
header('Location: detail.php?id=' . $movie_id);
exit;
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>添加电影 - 电影日志</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css">
<style>
.form-container {
max-width: 720px;
margin: 0 auto;
padding: 20px;
}
.form-card {
background: var(--card-bg);
border-radius: 16px;
padding: 32px 36px;
box-shadow: var(--shadow);
}
.form-card h2 {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px;
color: var(--text-primary);
}
.form-card .sub {
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
margin-bottom: 6px;
}
.form-group label .hint {
font-weight: 400;
color: var(--text-secondary);
font-size: 13px;
}
.form-group input, .form-group textarea, .form-group select {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: 10px;
font-size: 15px;
font-family: inherit;
background: var(--input-bg);
color: var(--text-primary);
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(230,126,34,0.12);
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.form-group input[type="file"] {
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
}
.form-group input[type="file"]:hover {
border-color: var(--primary);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-hint {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
.viewer-input-group {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 8px;
}
.viewer-input-group select, .viewer-input-group input {
flex: 1;
}
.viewer-input-group select {
width: 60%;
}
.viewer-input-group input[type="text"] {
width: 40%;
}
.btn-remove-viewer {
background: none;
border: none;
color: #e74c3c;
font-size: 20px;
cursor: pointer;
padding: 0 8px;
line-height: 1;
transition: transform 0.2s;
}
.btn-remove-viewer:hover {
transform: scale(1.2);
}
.btn-add-viewer {
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
border-radius: 8px;
padding: 8px 16px;
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
width: 100%;
}
.btn-add-viewer:hover {
border-color: var(--primary);
color: var(--primary);
background: rgba(230,126,34,0.05);
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
}
.form-actions .btn {
padding: 12px 32px;
font-size: 16px;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--border-color);
}
.error-box {
background: rgba(231,76,60,0.08);
border: 1px solid rgba(231,76,60,0.2);
color: #e74c3c;
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 20px;
font-size: 14px;
}
.cinema-new-group {
margin-top: 12px;
padding: 16px 18px;
background: var(--bg-secondary);
border-radius: 10px;
border: 1px solid var(--border-color);
}
.cinema-new-group .form-group:last-child {
margin-bottom: 0;
}
/* 裁剪预览 */
.image-preview {
max-width: 120px;
max-height: 120px;
border-radius: 4px;
border: 1px solid var(--border-color);
margin-top: 4px;
}
.crop-btn {
display: inline-block;
padding: 4px 12px;
background: #3498db;
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-top: 4px;
}
.crop-btn:hover { background: #2980b9; }
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 999;
justify-content: center;
align-items: center;
}
.modal-overlay.active { display: flex; }
.modal-content {
background: var(--card-bg);
max-width: 600px;
width: 90%;
padding: 24px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-header h3 { margin: 0; }
.modal-header .close-btn {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: var(--text-secondary);
}
.modal-body img { max-width: 100%; display: block; }
.modal-footer {
margin-top: 16px;
display: flex;
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>
<body>
<?php include 'nav.php'; ?>
<main class="main-content">
<div class="form-container">
<div class="form-card">
<h2>🎬 添加新电影</h2>
<p class="sub">记录一部你曾看过的电影</p>
<?php if ($error): ?>
<div class="error-box"><?= h($error) ?></div>
<?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; 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>
</div>
<!-- 电影信息 -->
<div class="form-group">
<label>电影信息</label>
<textarea name="info" id="movieInfo" placeholder="剧情简介、观影感受..."><?= h($_POST['info'] ?? '') ?></textarea>
</div>
<!-- 观影日期 -->
<div class="form-group">
<label>观影日期 <span class="hint">*必填</span></label>
<input type="date" name="watch_date" value="<?= h($_POST['watch_date'] ?? date('Y-m-d')) ?>" required>
</div>
<!-- 电影院 -->
<div class="form-group">
<label>电影院</label>
<select name="cinema_id" id="cinemaSelect" onchange="toggleNewCinema()">
<option value="">-- 不选择 --</option>
<?php foreach ($cinemas as $c): ?>
<option value="<?= $c['id'] ?>" <?= (isset($_POST['cinema_id']) && $_POST['cinema_id'] == $c['id']) ? 'selected' : '' ?>>
<?= h($c['place']) ?> · <?= h($c['name']) ?>
</option>
<?php endforeach; ?>
<option value="new" <?= (isset($_POST['cinema_id']) && $_POST['cinema_id'] === 'new') ? 'selected' : '' ?>>➕ 新建电影院</option>
</select>
</div>
<div class="cinema-new-group" id="newCinemaGroup" style="display: <?= (isset($_POST['cinema_id']) && $_POST['cinema_id'] === 'new') ? 'block' : 'none' ?>;">
<div class="form-group">
<label>所在地方 <span class="hint">如: 万达广场</span></label>
<input type="text" name="new_cinema_place" value="<?= h($_POST['new_cinema_place'] ?? '') ?>" placeholder="如: 万达广场">
</div>
<div class="form-group">
<label>电影院名称 <span class="hint">*必填</span></label>
<input type="text" name="new_cinema_name" value="<?= h($_POST['new_cinema_name'] ?? '') ?>" placeholder="如: 万达影城(IMAX店)">
</div>
<div class="form-group">
<label>详细地址</label>
<input type="text" name="new_cinema_address" value="<?= h($_POST['new_cinema_address'] ?? '') ?>" placeholder="详细地址">
</div>
</div>
<!-- 观影人 -->
<div class="form-group">
<label>一起看的人</label>
<div id="viewerContainer">
<?php
$viewer_selects = $_POST['viewer_select'] ?? [];
$viewer_news = $_POST['viewers_new'] ?? [];
if (empty($viewer_selects)) {
$viewer_selects = [''];
}
foreach ($viewer_selects as $idx => $val):
$new_name = $viewer_news[$idx] ?? '';
?>
<div class="viewer-input-group">
<select name="viewer_select[]" onchange="toggleNewViewer(this)" class="viewer-select" style="flex:1;padding:8px;border:1px solid #ddd;border-radius:6px;">
<option value="">-- 选择 --</option>
<?php foreach ($all_persons as $p): ?>
<option value="<?= $p['id'] ?>" <?= ($val == $p['id']) ? 'selected' : '' ?>><?= h($p['name']) ?></option>
<?php endforeach; ?>
<option value="new" <?= ($val === 'new') ? 'selected' : '' ?>>➕ 新增</option>
</select>
<input type="text" name="viewers_new[]" placeholder="输入新姓名" style="flex:1;display:<?= ($val === 'new') ? 'block' : 'none' ?>;padding:8px;border:1px solid #ddd;border-radius:6px;" value="<?= h($new_name) ?>">
<?php if ($idx > 0): ?>
<button type="button" class="btn-remove-viewer" onclick="removeViewerRow(this)">×</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="btn-add-viewer" onclick="addViewerRow()">➕ 添加一位</button>
<div class="form-hint">选择已有观影人,或选择“新增”后输入姓名</div>
</div>
<!-- 海报(直接上传 + 预览) -->
<div class="form-row">
<div class="form-group">
<label>电影海报</label>
<input type="file" name="poster" accept="image/*" id="posterInput">
<input type="hidden" name="fetched_poster_key" id="fetchedPosterKey" value="">
<div id="posterPreviewWrap" style="display:none;margin-top:6px;">
<img id="posterPreview" style="max-width:150px;max-height:200px;border-radius:4px;border:1px solid var(--border-color);">
<br>
<span id="posterPreviewHint" style="font-size:12px;color:var(--text-secondary);"></span>
</div>
<div class="form-hint">推荐比例 2:3(「自动获取」会连同海报一起下载;手动上传时以上传的为准)</div>
</div>
<!-- 票根(带裁剪) -->
<div class="form-group">
<label>电影票扫描件</label>
<input type="file" name="ticket" id="ticketInput" accept="image/*" onchange="handleTicketSelect(this)">
<img id="ticketPreview" class="image-preview" style="display:none;">
<input type="hidden" name="ticket_crop" id="ticket_crop">
</div>
</div>
<!-- 观影照片(多张) -->
<div class="form-group">
<label>观影照片 <span class="hint">可多选</span></label>
<input type="file" name="photos[]" accept="image/*" multiple>
<div class="form-hint">按住 Ctrl(或 ⌘)可一次选择多张</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">✅ 保存电影</button>
<a href="index.php" class="btn btn-secondary">取消</a>
</div>
</form>
</div>
</div>
</main>
<!-- 裁剪模态框 -->
<div class="modal-overlay" id="cropModal">
<div class="modal-content">
<div class="modal-header">
<h3>✂️ 裁剪票根</h3>
<button class="close-btn" onclick="closeCrop()">&times;</button>
</div>
<div class="modal-body">
<img id="cropImage" src="">
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeCrop()">取消</button>
<button class="btn btn-primary" onclick="cropSave()">✅ 确认裁剪</button>
</div>
</div>
</div>
<script src="js/main.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script>
<script>
// ---------- 观影人 ----------
function addViewerRow() {
var container = document.getElementById('viewerContainer');
var div = document.createElement('div');
div.className = 'viewer-input-group';
var selectHtml = '<select name="viewer_select[]" onchange="toggleNewViewer(this)" class="viewer-select" style="flex:1;padding:8px;border:1px solid #ddd;border-radius:6px;">';
selectHtml += '<option value="">-- 选择 --</option>';
<?php foreach ($all_persons as $p): ?>
selectHtml += '<option value="<?= $p['id'] ?>"><?= h($p['name']) ?></option>';
<?php endforeach; ?>
selectHtml += '<option value="new">➕ 新增</option>';
selectHtml += '</select>';
var inputHtml = '<input type="text" name="viewers_new[]" placeholder="输入新姓名" style="flex:1;display:none;padding:8px;border:1px solid #ddd;border-radius:6px;">';
var removeBtn = '<button type="button" class="btn-remove-viewer" onclick="removeViewerRow(this)">×</button>';
div.innerHTML = selectHtml + inputHtml + removeBtn;
container.appendChild(div);
}
document.addEventListener('DOMContentLoaded', function() {
toggleNewCinema();
document.querySelectorAll('.viewer-input-group').forEach(function(group) {
var select = group.querySelector('select');
if (select) toggleNewViewer(select);
});
});
// ---------- 票根裁剪 ----------
let cropper = null;
let currentFileInput = null;
function handleTicketSelect(input) {
const file = input.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('请选择图片文件');
input.value = '';
return;
}
currentFileInput = input;
const reader = new FileReader();
reader.onload = function(e) {
const img = document.getElementById('cropImage');
img.src = e.target.result;
document.getElementById('cropModal').classList.add('active');
if (cropper) cropper.destroy();
setTimeout(function() {
cropper = new Cropper(img, {
aspectRatio: NaN,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.8,
responsive: true,
restore: false,
});
}, 150);
};
reader.readAsDataURL(file);
}
function closeCrop() {
document.getElementById('cropModal').classList.remove('active');
if (cropper) {
cropper.destroy();
cropper = null;
}
}
function cropSave() {
if (!cropper) {
alert('请先加载图片');
return;
}
try {
const canvas = cropper.getCroppedCanvas({
width: 600,
height: 600,
imageSmoothingQuality: 'high',
});
if (!canvas) {
alert('裁剪失败,请重试');
return;
}
const base64 = canvas.toDataURL('image/jpeg', 0.9);
document.getElementById('ticket_crop').value = base64;
const preview = document.getElementById('ticketPreview');
preview.src = base64;
preview.style.display = 'block';
// 清空文件输入,避免同时提交原始文件
document.getElementById('ticketInput').value = '';
closeCrop();
} catch (e) {
alert('裁剪失败:' + e.message);
}
}
// 点击模态框外部关闭
document.getElementById('cropModal').addEventListener('click', function(e) {
if (e.target === this) {
closeCrop();
}
});
// ---------- 豆瓣自动获取(「按钮」与「下拉选中」共用同一套逻辑)----------
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');
// 豆瓣返回的文本一律先转义再插入,避免把对方内容当 HTML 执行
function acEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
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('⏳ 正在获取…');
// 上一次抓取但仍未采用的海报键,交给服务端清理,避免重复点击堆积文件
let url = 'fetch_movie.php?' + query;
const oldKey = fetchedKeyEl.value;
if (oldKey) url += '&old_poster=' + encodeURIComponent(oldKey);
return fetch(url)
.then(res => res.json())
.then(data => {
if (data.error) {
setFetchStatus('❌ ' + data.error, '#e74c3c');
return;
}
// 片名以豆瓣的正名称为准(自动补全/纠正)
if (data.title) movieTitleEl.value = data.title;
// 组装电影信息文本
let infoText = '';
if (data.year) infoText += '📅 年份:' + data.year + '\n';
if (data.country) infoText += '🌍 地区:' + data.country + '\n';
if (data.genre) infoText += '🏷️ 类型:' + data.genre + '\n';
if (data.runtime) infoText += '⏱️ 片长:' + data.runtime + '\n';
if (data.director) infoText += '🎬 导演:' + data.director + '\n';
if (data.actors) infoText += '👤 主演:' + data.actors + '\n';
if (data.rating) infoText += '⭐ 豆瓣评分:' + data.rating + '\n';
if (data.alias) infoText += '🔖 又名:' + data.alias + '\n';
if (data.plot) infoText += '\n📝 ' + data.plot;
movieInfoEl.value = infoText;
// 海报:已由服务端下载入库。这里记录键值(提交时写入 poster_path)并预览。
if (data.poster && data.poster_key) {
fetchedKeyEl.value = data.poster_key;
posterPreviewEl.src = data.poster;
posterHintEl.textContent = '海报已自动保存,提交时一并写入(若手动上传图片,以上传的为准)';
posterWrapEl.style.display = 'block';
setFetchStatus('✅ 已获取信息与海报', '#27ae60');
} else {
fetchedKeyEl.value = '';
posterWrapEl.style.display = 'none';
setFetchStatus('✅ 已获取基本信息(该片暂无海报)', '#27ae60');
}
})
.catch(err => {
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));
});
// ---------- 片名下拉联想 ----------
// 输入几个字 → 服务端只做一次搜索(不取详情、不下载海报)→ 下拉展示候选。
// 选中后按豆瓣条目 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>