hengdian / userscripts /tms-content-query.user.js
Codex
Upload current Hugging Face Space snapshot
732907b
Raw
History Blame Contribute Delete
59.6 kB
// ==UserScript==
// @name TMS 服务器影片内容查询助手
// @name:zh-CN TMS 服务器影片内容查询助手
// @namespace https://tms.hengdianfilm.com/
// @version 1.3.8
// @description 在 TMS 服务器内容页注入「🔍 一键查询 TMS 内容」按钮,弹窗以「🎥 按影片」「🏢 按影厅」两种视图展示 DCP 内容,支持全部表头点击排序。
// @description:zh-CN 在 TMS 服务器内容页注入「🔍 一键查询 TMS 内容」按钮,弹窗以「🎥 按影片」「🏢 按影厅」两种视图展示 DCP 内容,支持全部表头点击排序。
// @author pzt
// @match *://tms.hengdianfilm.com/*
// @run-at document-start
// @grant GM_addStyle
// @grant unsafeWindow
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ============================================================
// 0. 跨 frame 共享状态:所有 frame 都注入此脚本,但 Token 只往 window.top 写
// (TMS 服务器内容页是 EasyUI iframe 结构,列表 XHR 在内部 frame 发出)
// ============================================================
const PAGE_WINDOW = (() => {
try {
return (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
} catch (e) {
return window;
}
})();
function safeSessionGet(key) {
try { return sessionStorage.getItem(`tms_helper_${key}`) || ''; } catch (e) { return ''; }
}
function safeSessionSet(key, value) {
try { sessionStorage.setItem(`tms_helper_${key}`, String(value || '')); } catch (e) { /* noop */ }
}
function safeLocalGet(key) {
try { return localStorage.getItem(`tms_helper_${key}`) || ''; } catch (e) { return ''; }
}
function safeLocalSet(key, value) {
try { localStorage.setItem(`tms_helper_${key}`, String(value || '')); } catch (e) { /* noop */ }
}
const SHARED_KEY = '__TMS_HELPER_SHARED__';
let sharedHost;
try {
sharedHost = (PAGE_WINDOW && PAGE_WINDOW.top) || window.top || window;
} catch (e) {
sharedHost = window;
}
if (!sharedHost[SHARED_KEY]) {
sharedHost[SHARED_KEY] = {
token: safeSessionGet('token'),
theaterId: safeSessionGet('theaterId'),
xSessionId: safeSessionGet('xSessionId'),
lastResponse: null,
installed: false,
};
}
const SHARED = sharedHost[SHARED_KEY];
// 当前 frame 也维护一个本地 fallback,以防 cross-origin 抢不到 top
const LOCAL = {
token: safeSessionGet('token'),
theaterId: safeSessionGet('theaterId'),
xSessionId: safeSessionGet('xSessionId'),
};
function rememberToken(value) {
const v = String(value || '').trim();
if (!v) return;
try { SHARED.token = v; } catch (e) { /* noop */ }
LOCAL.token = v;
safeSessionSet('token', v);
}
function rememberTheaterId(value) {
const v = String(value || '').trim();
if (!v) return;
try { SHARED.theaterId = v; } catch (e) { /* noop */ }
LOCAL.theaterId = v;
safeSessionSet('theaterId', v);
}
function rememberXSessionId(value) {
const v = String(value || '').trim();
if (!v) return;
try { SHARED.xSessionId = v; } catch (e) { /* noop */ }
LOCAL.xSessionId = v;
safeSessionSet('xSessionId', v);
}
function readToken() {
return (SHARED && SHARED.token) || LOCAL.token || safeSessionGet('token') || '';
}
function readTheaterId() {
return (SHARED && SHARED.theaterId) || LOCAL.theaterId || safeSessionGet('theaterId') || '';
}
function readXSessionId() {
return (SHARED && SHARED.xSessionId) || LOCAL.xSessionId || safeSessionGet('xSessionId') || '';
}
function captureFromUrl(url) {
try {
const u = new URL(url, location.href);
const tid = u.searchParams.get('THEATER_ID');
if (tid) rememberTheaterId(tid);
} catch (e) { /* noop */ }
}
// 当前 frame 自己 URL 里的 THEATER_ID
captureFromUrl(location.href);
// ============================================================
// 1. XHR 拦截
// ============================================================
function patchXhr(win) {
try {
if (!win || !win.XMLHttpRequest || !win.XMLHttpRequest.prototype) return;
const proto = win.XMLHttpRequest.prototype;
if (proto.__tmsHelperPatched) return;
const _open = proto.open;
const _setHeader = proto.setRequestHeader;
const _send = proto.send;
try {
Object.defineProperty(proto, '__tmsHelperPatched', { value: true, configurable: false });
} catch (e) {
proto.__tmsHelperPatched = true;
}
proto.open = function (method, url) {
this.__tms_url = String(url || '');
this.__tms_method = String(method || '').toUpperCase();
captureFromUrl(this.__tms_url);
return _open.apply(this, arguments);
};
proto.setRequestHeader = function (name, value) {
try {
const lowerName = typeof name === 'string' ? name.toLowerCase() : '';
if (lowerName === 'token' && value) rememberToken(value);
if (lowerName === 'x-sessionid' && value) rememberXSessionId(value);
} catch (e) { /* noop */ }
return _setHeader.apply(this, arguments);
};
proto.send = function () {
const url = this.__tms_url || '';
if (url.indexOf('/cinema-api/cinema/server/dcp/list') !== -1) {
this.addEventListener('load', () => {
try {
const json = JSON.parse(this.responseText);
SHARED.lastResponse = json;
} catch (e) { /* noop */ }
});
}
return _send.apply(this, arguments);
};
} catch (e) { /* noop */ }
}
patchXhr(window);
if (PAGE_WINDOW !== window) patchXhr(PAGE_WINDOW);
// ============================================================
// 2. fetch 拦截(兜底,以防新版页面切到 fetch)
// ============================================================
function rememberHeaders(headers) {
if (!headers) return;
try {
if (typeof headers.get === 'function') {
const t = headers.get('Token') || headers.get('token');
const xs = headers.get('X-SESSIONID') || headers.get('x-sessionid');
if (t) rememberToken(t);
if (xs) rememberXSessionId(xs);
return;
}
if (Array.isArray(headers)) {
headers.forEach(([k, v]) => {
const lower = String(k || '').toLowerCase();
if (lower === 'token' && v) rememberToken(v);
if (lower === 'x-sessionid' && v) rememberXSessionId(v);
});
return;
}
if (typeof headers === 'object') {
for (const k of Object.keys(headers)) {
const lower = k.toLowerCase();
if (lower === 'token' && headers[k]) rememberToken(headers[k]);
if (lower === 'x-sessionid' && headers[k]) rememberXSessionId(headers[k]);
}
}
} catch (e) { /* noop */ }
}
function patchFetch(win) {
try {
if (!win || typeof win.fetch !== 'function' || win.__tmsHelperFetchPatched) return;
const _fetch = win.fetch.bind(win);
try {
Object.defineProperty(win, '__tmsHelperFetchPatched', { value: true, configurable: false });
} catch (e) {
win.__tmsHelperFetchPatched = true;
}
win.fetch = function (input, init) {
try {
const url = typeof input === 'string' ? input : (input && input.url) || '';
captureFromUrl(url);
rememberHeaders((init && init.headers) || (typeof input === 'object' && input ? input.headers : null));
} catch (e) { /* noop */ }
return _fetch(input, init);
};
} catch (e) { /* noop */ }
}
patchFetch(window);
if (PAGE_WINDOW !== window) patchFetch(PAGE_WINDOW);
// ============================================================
// 3. 仅在顶层 frame 注入 UI
// ============================================================
let isTop = false;
try { isTop = window.top === window; } catch (e) { isTop = true; }
if (!isTop) return;
// 同源 frame 内的 location 也读一下,提前拿到 THEATER_ID
function scanIframesForTheaterId() {
try {
const iframes = document.getElementsByTagName('iframe');
for (const f of iframes) {
try {
const url = f.contentWindow && f.contentWindow.location && f.contentWindow.location.href;
if (url) captureFromUrl(url);
} catch (e) { /* cross-origin or not loaded */ }
try {
if (f.src) captureFromUrl(f.src);
} catch (e) { /* noop */ }
}
} catch (e) { /* noop */ }
}
// ============================================================
// 4. 工具函数(与 Python 端一致)
// ============================================================
function formatHallDisplay(hallName) {
const raw = String(hallName == null ? '' : hallName).trim();
const match = raw.match(/\d+/);
return match ? match[0] : raw;
}
function formatPlayTime(timeStr) {
if (!timeStr || typeof timeStr !== 'string') return null;
const parts = timeStr.split(':');
if (parts.length < 2) return null;
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
if (isNaN(h) || isNaN(m)) return null;
return h * 60 + m;
}
function formatContentNameWithExplanation(contentName) {
const raw = String(contentName == null ? '' : contentName).trim();
if (!raw) return '';
const langMap = {
CMN: '国语/普通话', YUE: '粤语', EN: '英语', JP: '日语/或简化命名中的加密标记',
KO: '韩语', FR: '法语', ES: '西班牙语', TH: '泰语', HI: '印地语', RU: '俄语',
PTH: '普通话', GDH: '广东话', YS: '原声', YZ: '译制', SCH: '四川话',
NAN: '闽南语', WU: '吴语/上海话', XX: '无字幕', QMS: '简中字幕',
QMT: '繁中字幕', CCAP: '听障字幕'
};
const audioMap = { '20': '2.0', '51': '5.1', '71': '7.1', ATMOS: 'Dolby Atmos', DTSX: 'DTS:X' };
const typeMap = { FTR: '正片', TLR: '预告片', TSR: '先导预告' };
const packMap = { OV: '原始版本包', VF: '版本增量包' };
const notes = [];
const parts = raw.split('_');
const firstTokens = parts.length > 0 ? parts[0].split('-') : [];
if (firstTokens.length > 0) {
notes.push(`[片名/标识:${firstTokens[0]}]`);
for (let i = 1; i < firstTokens.length; i++) {
const token = firstTokens[i];
const up = token.toUpperCase();
if (typeMap[up]) {
notes.push(`[内容类型:${typeMap[up]}(${token})]`);
} else if (up === '2D' || up === '3D') {
notes.push(`[制式:${up}]`);
} else if (['4FL', '24FPS', '48FPS', '60FPS', '120FPS'].includes(up)) {
notes.push(`[技术参数:${token}]`);
} else if (/^\d+$/.test(up)) {
notes.push(`[版本号:${token}]`);
} else {
notes.push(`[${token}]`);
}
}
}
for (let i = 1; i < parts.length; i++) {
const token = parts[i];
const up = token.toUpperCase();
if (up.indexOf('-') !== -1) {
const [a, b] = up.split('-', 2);
if (langMap[a] && langMap[b]) {
notes.push(`[音频:${langMap[a]}(${a})]`);
notes.push(`[字幕:${langMap[b]}(${b})]`);
continue;
}
}
if (['F', 'S', 'C', 'F-178', 'C-19', '235', '185'].includes(up)) {
notes.push(`[画幅:${token}]`);
} else if (/^\d{2,3}M$/.test(up)) {
notes.push(`[时长:${token}]`);
} else if (audioMap[up]) {
notes.push(`[音效:${audioMap[up]}(${token})]`);
} else if (up === '2K' || up === '4K') {
notes.push(`[分辨率:${up}]`);
} else if (up === 'SMPTE' || up === 'IOP') {
notes.push(`[封装标准:${up}]`);
} else if (/^\d{8}$/.test(up)) {
notes.push(`[打包日期:${token}]`);
} else if (/^\d{4}$/.test(up)) {
notes.push(`[月日批次:${token}]`);
} else if (packMap[up]) {
notes.push(`[包类型:${packMap[up]}(${up})]`);
} else if (langMap[up]) {
notes.push(`[语言/标记:${langMap[up]}(${up})]`);
} else if (up.indexOf('CN') === 0) {
notes.push(`[地区/分级:${token}]`);
} else {
notes.push(`[${token}]`);
}
}
return `${raw} / ${notes.join(' ')}`;
}
function cleanText(value) {
const text = String(value == null ? '' : value).trim();
if (!text || text === 'null' || text === 'undefined') return '';
return text;
}
function getFirstField(obj, keys) {
if (!obj || typeof obj !== 'object') return '';
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
const value = cleanText(obj[key]);
if (value) return value;
}
return '';
}
function uniqueSortedText(values) {
return Array.from(new Set(values.map(cleanText).filter(Boolean)))
.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN', { numeric: true }));
}
function extractHallNamesFromValue(value) {
if (value == null) return [];
if (Array.isArray(value)) return value.flatMap(extractHallNamesFromValue);
if (typeof value === 'string' || typeof value === 'number') {
const text = cleanText(value);
if (!text) return [];
if (text[0] === '[' || text[0] === '{') {
try {
return extractHallNamesFromValue(JSON.parse(text));
} catch (e) { /* fall through to delimiter split */ }
}
return text.split(/[、,,;;|/]+/).map(cleanText).filter(Boolean);
}
if (typeof value === 'object') {
const name = getFirstField(value, [
'HALL_NAME', 'hallName', 'hall_name', 'HALL_NM', 'HALLNAME',
'NAME', 'name', 'label', 'text',
]);
if (name) return extractHallNamesFromValue(name);
const nested = Object.values(value).filter(v => v && typeof v === 'object');
if (nested.length) return nested.flatMap(extractHallNamesFromValue);
const id = getFirstField(value, ['HALL_ID', 'hallId', 'hall_id']);
return id ? [id] : [];
}
return [];
}
function getMovieHallNames(movie) {
const rawValues = [
movie.HALL_INFO, movie.hallInfo, movie.hall_info,
movie.HALLS, movie.halls,
movie.HALL_NAME, movie.HALL_NAMES, movie.HALL_NM, movie.HALL,
movie.hallName, movie.hallNames, movie.hall_name,
];
return uniqueSortedText(rawValues.flatMap(extractHallNamesFromValue));
}
function buildTmsJsonHeaders() {
const token = readToken();
if (!token) {
throw new Error('尚未捕获到登录 Token。请先在 TMS 页面进入「服务器内容」Tab 让它自然加载一次列表,再点本按钮。');
}
const headers = {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Content-Type': 'application/json; charset=UTF-8',
'Token': token,
'X-Requested-With': 'XMLHttpRequest',
};
const xSessionId = readXSessionId();
if (xSessionId) headers['X-SESSIONID'] = xSessionId;
return headers;
}
// ============================================================
// 5. 拉取 dcp/list 全量数据
// ============================================================
async function fetchDcpList() {
scanIframesForTheaterId();
const theaterIdRaw = readTheaterId();
const theaterId = parseInt(theaterIdRaw, 10);
if (!theaterId) {
throw new Error('未找到 THEATER_ID。请先点开「服务器内容」Tab 并加载一次列表,或确保当前 URL 含 THEATER_ID 参数。');
}
const all = [];
let pageIndex = 1;
const pageSize = 200;
while (true) {
const resp = await fetch('https://tms.hengdianfilm.com/cinema-api/cinema/server/dcp/list?token=hd&murl=ContentMovie', {
method: 'POST',
credentials: 'include',
headers: buildTmsJsonHeaders(),
body: JSON.stringify({
THEATER_ID: theaterId,
SOURCE: 'SERVER',
ASSERT_TYPE: 2,
PAGE_CAPACITY: pageSize,
PAGE_INDEX: pageIndex,
}),
});
if (!resp.ok) {
throw new Error(`接口请求失败 HTTP ${resp.status}`);
}
const data = await resp.json();
if (data.RSPCD !== '000000') {
throw new Error(`接口返回错误:${data.RSPMSG || data.RSPCD}`);
}
const body = data.BODY || {};
const list = body.LIST || [];
all.push(...list);
const total = body.COUNT || 0;
if (list.length === 0 || all.length >= total) break;
pageIndex += 1;
}
return all;
}
async function fetchHallStatus() {
scanIframesForTheaterId();
const theaterIdRaw = readTheaterId();
const theaterId = parseInt(theaterIdRaw, 10);
if (!theaterId) {
throw new Error('未找到 THEATER_ID,无法查询服务器在线状态。');
}
const all = [];
let pageIndex = 1;
const pageSize = 20;
while (true) {
const resp = await fetch('https://tms.hengdianfilm.com/cinema-api/cinema/hall/list?token=hd&murl=CinemaList', {
method: 'POST',
credentials: 'include',
headers: buildTmsJsonHeaders(),
body: JSON.stringify({
PAGE_INDEX: pageIndex,
THEATER_ID: String(theaterId),
PAGE_CAPACITY: pageSize,
}),
});
if (!resp.ok) {
throw new Error(`影厅状态接口请求失败 HTTP ${resp.status}`);
}
const data = await resp.json();
if (data.RSPCD !== '000000') {
throw new Error(`影厅状态接口返回错误:${data.RSPMSG || data.RSPCD}`);
}
const body = data.BODY || {};
const list = body.LIST || [];
all.push(...list);
const total = body.COUNT || 0;
if (list.length === 0 || all.length >= total) break;
pageIndex += 1;
}
return processHallStatus(all);
}
function processHallStatus(halls) {
const sorted = (halls || []).slice().sort((a, b) => {
const av = formatHallDisplay(a.NAME || a.OUTER_ID || a.HALL_NAME || '');
const bv = formatHallDisplay(b.NAME || b.OUTER_ID || b.HALL_NAME || '');
return av.localeCompare(bv, 'zh-Hans-CN', { numeric: true });
});
const onlineHalls = sorted.filter(h => Number(h.STATUS) === 1);
const offlineHalls = sorted.filter(h => Number(h.STATUS) !== 1);
return {
total: sorted.length,
online: onlineHalls.length,
offline: offlineHalls.length,
offlineNames: offlineHalls.map(h => formatHallDisplay(h.NAME || h.OUTER_ID || h.HALL_NAME || '未知')).filter(Boolean),
halls: sorted,
};
}
async function fetchTmsLogRefreshTimes() {
scanIframesForTheaterId();
const theaterIdRaw = readTheaterId();
const theaterId = parseInt(theaterIdRaw, 10);
if (!theaterId) {
throw new Error('未找到 THEATER_ID,无法查询 TMS 日志刷新时间。');
}
const resp = await fetch('https://tms.hengdianfilm.com/cinema-api/cinema/trace/list?token=hd&murl=CinemaTransportLog', {
method: 'POST',
credentials: 'include',
headers: buildTmsJsonHeaders(),
body: JSON.stringify({
PAGE_INDEX: 1,
THEATER_ID: theaterId,
PAGE_CAPACITY: 5,
}),
});
if (!resp.ok) {
throw new Error(`TMS 日志接口请求失败 HTTP ${resp.status}`);
}
const data = await resp.json();
if (data.RSPCD !== '000000') {
throw new Error(`TMS 日志接口返回错误:${data.RSPMSG || data.RSPCD}`);
}
const body = data.BODY || {};
return processTmsLogRefreshTimes(body.LIST || []);
}
function formatLogTime(value) {
const text = String(value == null ? '' : value).trim();
const match = text.match(/(?:^|\s)(\d{1,2}):(\d{2})(?::\d{2})?/);
if (!match) return '';
return `${match[1].padStart(2, '0')}:${match[2]}`;
}
function processTmsLogRefreshTimes(rows) {
return (rows || [])
.slice(0, 5)
.map(row => formatLogTime(row && (row.CREATE_TIME || row.createTime || row.LOG_TIME || row.logTime)))
.filter(Boolean);
}
// ============================================================
// 6. 数据加工:仿 Python 端 process_tms_movies
// ============================================================
function processMovies(allMovies) {
const movieDetails = {};
let rowsWithHall = 0;
let duplicateContentNames = 0;
for (const m of allMovies) {
const cn = getFirstField(m, ['CONTENT_NAME', 'contentName', 'content_name']);
if (!cn) continue;
const halls = getMovieHallNames(m);
if (halls.length) rowsWithHall += 1;
if (movieDetails[cn]) duplicateContentNames += 1;
const previous = movieDetails[cn] || {
assert_name: '',
assert_id: '',
halls: [],
play_time: '',
};
previous.assert_name = previous.assert_name || getFirstField(m, ['ASSERT_NAME', 'assertName', 'assert_name']);
previous.assert_id = previous.assert_id || getFirstField(m, ['ASSERT_ID', 'assertId', 'assert_id']);
previous.play_time = previous.play_time || getFirstField(m, ['PLAY_TIME', 'playTime', 'play_time']);
previous.halls = uniqueSortedText([...(previous.halls || []), ...halls]);
movieDetails[cn] = previous;
}
const byHall = {};
for (const [cn, d] of Object.entries(movieDetails)) {
for (const hallName of d.halls) {
if (!byHall[hallName]) byHall[hallName] = [];
byHall[hallName].push({ content_name: cn, details: d });
}
}
for (const hallName of Object.keys(byHall)) {
byHall[hallName].sort((a, b) => {
const an = a.details.assert_name || a.content_name;
const bn = b.details.assert_name || b.content_name;
const aEmpty = !a.details.assert_name ? 1 : 0;
const bEmpty = !b.details.assert_name ? 1 : 0;
if (aEmpty !== bEmpty) return aEmpty - bEmpty;
return an.localeCompare(bn, 'zh-Hans-CN');
});
}
const sortedHalls = Object.keys(byHall).sort((a, b) => a.localeCompare(b, 'zh-Hans-CN', { numeric: true }));
const orderedByHall = {};
for (const k of sortedHalls) orderedByHall[k] = byHall[k];
const byMovie = Object.entries(movieDetails)
.filter(([, d]) => d.assert_name)
.map(([cn, d]) => ({
assert_name: d.assert_name,
assert_id: d.assert_id,
content_name: cn,
halls: d.halls,
play_time: d.play_time,
}))
.sort((a, b) => a.assert_name.localeCompare(b.assert_name, 'zh-Hans-CN'));
return {
halls: orderedByHall,
movies: byMovie,
stats: {
rawRows: allMovies.length,
contentRows: Object.keys(movieDetails).length,
rowsWithHall,
duplicateContentNames,
},
};
}
// ============================================================
// 7. UI:浮动按钮 + Shadow DOM 弹窗(避免被 EasyUI 全局样式污染)
// ============================================================
GM_addStyle(`
#tms-helper-fab {
position: fixed; right: 24px; bottom: 24px; z-index: 2147483646;
background: linear-gradient(135deg,#3b82f6,#8b5cf6);
color: #fff; border: none; border-radius: 999px;
padding: 12px 18px; font-size: 14px; font-weight: 600;
box-shadow: 0 8px 24px rgba(59,130,246,.35);
cursor: grab; user-select: none; touch-action: none;
transition: transform .15s ease, box-shadow .15s ease;
font-family: -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
}
#tms-helper-fab:hover { transform: translateY(-2px); box-shadow: 0 10px 28px rgba(59,130,246,.45); }
#tms-helper-fab.tms-dragging { cursor: grabbing; transform: none; box-shadow: 0 12px 34px rgba(59,130,246,.48); }
#tms-helper-fab[disabled] { opacity: .65; cursor: progress; }
#tms-helper-fab .tms-fab-status {
display: inline-block; margin-left: 8px; padding: 2px 6px;
font-size: 11px; border-radius: 8px;
background: rgba(255,255,255,.22);
}
/* Shadow Host 仅占满屏遮罩,UI 全部走 Shadow DOM */
#tms-helper-host {
position: fixed; inset: 0; z-index: 2147483647;
display: none;
}
#tms-helper-host.show { display: block; }
`);
const SHADOW_STYLES = `
:host { all: initial; }
* { box-sizing: border-box; }
.mask {
position: fixed; inset: 0; background: rgba(15,23,42,.55);
display: flex; align-items: center; justify-content: center;
font-family: -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
color: #0f172a; line-height: 1.5;
}
.modal {
background: #fff; width: min(96vw, 1180px); max-height: 90vh;
border-radius: 12px; overflow: hidden;
display: flex; flex-direction: column;
box-shadow: 0 24px 60px rgba(0,0,0,.35);
}
.header {
padding: 14px 20px; display: flex; align-items: center; justify-content: space-between;
background: linear-gradient(135deg,#1e293b,#334155); color: #fff;
}
.header .title { font-size: 16px; font-weight: 700; }
.header .meta { font-size: 12px; opacity: .85; margin-top: 2px; }
.close {
background: rgba(255,255,255,.15); color: #fff; border: none; border-radius: 6px;
padding: 6px 10px; cursor: pointer; font-size: 13px;
}
.close:hover { background: rgba(255,255,255,.28); }
.tabs {
display: flex; border-bottom: 1px solid #e2e8f0; background: #f8fafc;
padding: 0 12px; overflow-x: auto;
}
.tab {
padding: 10px 14px; font-size: 13px; cursor: pointer;
border-bottom: 2px solid transparent; color: #475569; white-space: nowrap;
}
.tab.active { color: #2563eb; border-bottom-color: #2563eb; font-weight: 600; }
.tab:hover { color: #1d4ed8; }
.server-status {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 9px 16px; border-bottom: 1px solid #e2e8f0;
background: #f8fafc; color: #334155; font-size: 13px;
}
.server-status strong { font-size: 15px; color: #0f172a; }
.server-status .status-dot {
width: 9px; height: 9px; border-radius: 50%; background: #94a3b8;
box-shadow: 0 0 0 3px rgba(148,163,184,.16);
}
.server-status-ok .status-dot { background: #16a34a; box-shadow: 0 0 0 3px rgba(22,163,74,.16); }
.server-status-warn .status-dot { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245,158,11,.18); }
.server-status-error .status-dot { background: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,.16); }
.server-status .offline-list { color: #b91c1c; font-weight: 700; }
.server-status .log-times { color: #475569; font-weight: 600; }
.server-status .log-times-error { color: #b91c1c; }
.body { padding: 12px 16px; overflow: auto; flex: 1; background: #fff; }
.pane { display: block; scroll-margin-top: 12px; }
.pane + .pane { margin-top: 18px; padding-top: 18px; border-top: 1px solid #e2e8f0; }
.section-title { margin: 0 0 8px; font-size: 16px; color: #0f172a; font-weight: 700; }
.table-wrap { overflow: auto; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; }
table.tbl { border-collapse: collapse; width: max-content; min-width: 100%; font-size: 12.5px; background: #fff; }
table.tbl th, table.tbl td {
padding: 6px 10px; border-bottom: 1px solid #f1f5f9; vertical-align: top;
text-align: left; line-height: 1.5; color: #0f172a;
}
table.tbl thead th {
position: sticky; top: 0; background: #f1f5f9; color: #0f172a; z-index: 1;
font-weight: 600; border-bottom: 1px solid #cbd5e1;
}
table.tbl tbody tr:hover { background: #f8fafc; }
table.tbl th.sortable { cursor: pointer; user-select: none; }
table.tbl th.sortable:hover { background: #e2e8f0; }
table.tbl th .sort-arrow { color: #94a3b8; margin-left: 4px; font-size: 11px; }
table.tbl th.sort-asc .sort-arrow,
table.tbl th.sort-desc .sort-arrow { color: #2563eb; }
table.tbl th:not(.col-contentExplained),
table.tbl td:not(.content) { white-space: nowrap; }
.halls {
color: #b91c1c; font-weight: 900; letter-spacing: 5px;
font-size: 23px; line-height: 1.15;
text-shadow: 0 1px 0 rgba(127,29,29,.12);
}
.content {
color: #475569; max-width: 360px; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.empty { color: #94a3b8; padding: 24px; text-align: center; }
.err { color: #b91c1c; padding: 12px 16px; background: #fef2f2;
border: 1px solid #fecaca; border-radius: 8px; margin: 12px; }
.summary { color: #64748b; font-size: 12px; margin: 4px 0 8px; }
.hall-section {
margin: 0 0 14px 0; border: 1px solid #e2e8f0; border-radius: 8px;
overflow: hidden; background: #fff; display: block;
}
.hall-section .hall-summary {
cursor: pointer;
padding: 10px 14px; background: #f8fafc; color: #0f172a;
font-size: 13px; font-weight: 600;
display: flex; align-items: center; justify-content: space-between;
user-select: none;
}
.hall-section .hall-summary:hover { background: #eef2ff; }
.hall-summary-left { display: flex; align-items: center; gap: 8px; }
.hall-toggle {
display: inline-block; width: 14px; text-align: center; color: #64748b;
transition: transform .15s ease;
}
.hall-section.open .hall-toggle { transform: rotate(90deg); color: #2563eb; }
.hall-badge {
display: inline-block; min-width: 58px; padding: 0 10px; height: 32px; line-height: 32px;
text-align: center; border-radius: 8px;
background: #b91c1c; color: #fff; font-size: 17px; font-weight: 900;
}
.hall-count { color: #64748b; font-weight: 500; font-size: 12px; }
.hall-section.open .hall-summary { background: #eef2ff; }
.hall-body { padding: 8px 12px 12px; background: #fff; display: none; }
.hall-section.open .hall-body { display: block; }
.toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.toolbar .btn {
padding: 4px 10px; font-size: 12px; cursor: pointer;
border: 1px solid #cbd5e1; background: #fff; color: #334155; border-radius: 6px;
}
.toolbar .btn:hover { border-color: #2563eb; color: #1d4ed8; }
.search-input {
height: 28px; min-width: 260px; padding: 4px 10px;
border: 1px solid #cbd5e1; border-radius: 6px;
font-size: 12px; color: #0f172a; outline: none;
}
.search-input:focus {
border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,.12);
}
.search-count { color: #2563eb; font-size: 12px; font-weight: 600; }
`;
let SHADOW = null;
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function clampNumber(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function readFabPosition() {
const raw = safeLocalGet('fab_position');
if (!raw) return null;
try {
const pos = JSON.parse(raw);
if (Number.isFinite(pos.left) && Number.isFinite(pos.top)) return pos;
} catch (e) { /* noop */ }
return null;
}
function placeFab(btn, left, top) {
const rect = btn.getBoundingClientRect();
const margin = 8;
const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
const nextLeft = clampNumber(left, margin, maxLeft);
const nextTop = clampNumber(top, margin, maxTop);
btn.style.left = `${nextLeft}px`;
btn.style.top = `${nextTop}px`;
btn.style.right = 'auto';
btn.style.bottom = 'auto';
safeLocalSet('fab_position', JSON.stringify({ left: nextLeft, top: nextTop }));
}
function restoreFabPosition(btn) {
const pos = readFabPosition();
if (!pos) return;
requestAnimationFrame(() => placeFab(btn, pos.left, pos.top));
}
function makeFabDraggable(btn) {
let dragState = null;
let suppressClick = false;
const moveThreshold = 5;
btn.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 || btn.disabled) return;
const rect = btn.getBoundingClientRect();
dragState = {
pointerId: ev.pointerId,
startX: ev.clientX,
startY: ev.clientY,
offsetX: ev.clientX - rect.left,
offsetY: ev.clientY - rect.top,
moved: false,
};
try { btn.setPointerCapture(ev.pointerId); } catch (e) { /* noop */ }
});
btn.addEventListener('pointermove', (ev) => {
if (!dragState || ev.pointerId !== dragState.pointerId) return;
const dx = ev.clientX - dragState.startX;
const dy = ev.clientY - dragState.startY;
if (!dragState.moved && Math.hypot(dx, dy) < moveThreshold) return;
dragState.moved = true;
suppressClick = true;
btn.classList.add('tms-dragging');
placeFab(btn, ev.clientX - dragState.offsetX, ev.clientY - dragState.offsetY);
ev.preventDefault();
});
function finishDrag(ev) {
if (!dragState || ev.pointerId !== dragState.pointerId) return;
try { btn.releasePointerCapture(ev.pointerId); } catch (e) { /* noop */ }
btn.classList.remove('tms-dragging');
dragState = null;
setTimeout(() => { suppressClick = false; }, 0);
}
btn.addEventListener('pointerup', finishDrag);
btn.addEventListener('pointercancel', finishDrag);
btn.addEventListener('click', (ev) => {
if (suppressClick) {
ev.preventDefault();
ev.stopImmediatePropagation();
return;
}
onQueryClick();
});
window.addEventListener('resize', () => {
const rect = btn.getBoundingClientRect();
if (btn.style.left && btn.style.top) placeFab(btn, rect.left, rect.top);
});
}
function ensureFab() {
if (document.getElementById('tms-helper-fab')) return;
if (!document.body) return;
const btn = document.createElement('button');
btn.id = 'tms-helper-fab';
btn.type = 'button';
btn.innerHTML = '🔍 一键查询 TMS 内容<span class="tms-fab-status" id="tms-fab-status">等待登录态…</span>';
btn.title = '单击查询;按住鼠标左键可拖动位置';
makeFabDraggable(btn);
document.body.appendChild(btn);
restoreFabPosition(btn);
setInterval(() => {
const status = document.getElementById('tms-fab-status');
if (!status) return;
scanIframesForTheaterId();
const hasTok = !!readToken();
const hasTid = !!readTheaterId();
if (hasTok && hasTid) {
status.textContent = '已就绪';
status.style.background = 'rgba(34,197,94,.35)';
} else if (hasTok) {
status.textContent = '缺 THEATER_ID';
status.style.background = 'rgba(234,179,8,.35)';
} else if (hasTid) {
status.textContent = '等 Token…';
status.style.background = 'rgba(234,179,8,.35)';
} else {
status.textContent = '等待登录态…';
status.style.background = 'rgba(255,255,255,.22)';
}
}, 1000);
}
function buildModalShell() {
if (SHADOW) return;
const host = document.createElement('div');
host.id = 'tms-helper-host';
document.body.appendChild(host);
SHADOW = host.attachShadow({ mode: 'open' });
const styleEl = document.createElement('style');
styleEl.textContent = SHADOW_STYLES;
SHADOW.appendChild(styleEl);
const wrapper = document.createElement('div');
wrapper.className = 'mask';
wrapper.innerHTML = `
<div class="modal" role="dialog" aria-modal="true">
<div class="header">
<div>
<div class="title">🎬 TMS 服务器影片内容查询</div>
<div class="meta" data-id="meta">数据来源:/cinema-api/cinema/server/dcp/list</div>
</div>
<button class="close" type="button" data-id="close">关闭 ✕</button>
</div>
<div class="tabs" data-id="tabs"></div>
<div class="server-status" data-id="server-status">
<span class="status-dot"></span>
<span>服务器在线状态:待查询</span>
</div>
<div class="body" data-id="body"></div>
</div>
`;
SHADOW.appendChild(wrapper);
wrapper.addEventListener('click', (ev) => {
if (ev.target === wrapper) closeModal();
});
SHADOW.querySelector('[data-id="close"]').addEventListener('click', closeModal);
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape' && host.classList.contains('show')) closeModal();
});
}
function $shadow(selector) {
return SHADOW ? SHADOW.querySelector(selector) : null;
}
function renderServerStatus(status) {
const el = $shadow('[data-id="server-status"]');
if (!el) return;
el.classList.remove('server-status-ok', 'server-status-warn', 'server-status-error');
const buildLogText = () => {
const times = Array.isArray(status && status.logTimes) ? status.logTimes : [];
if (status && status.logError) {
return '<span class="log-times log-times-error">日志刷新:获取失败</span>';
}
if (times.length) {
return `<span class="log-times">日志刷新:${escapeHtml(times.join(' / '))}</span>`;
}
return '<span class="log-times">日志刷新:暂无</span>';
};
if (!status || status.loading) {
el.innerHTML = '<span class="status-dot"></span><span>服务器在线状态:查询中...</span><span class="log-times">日志刷新:查询中...</span>';
return;
}
if (status.error) {
el.classList.add('server-status-error');
el.innerHTML = `<span class="status-dot"></span><span>服务器在线状态:获取失败</span><span class="offline-list">${escapeHtml(status.error)}</span>${buildLogText()}`;
return;
}
const total = Number(status.total || 0);
const online = Number(status.online || 0);
const offline = Number(status.offline || 0);
if (total > 0 && offline === 0) {
el.classList.add('server-status-ok');
} else {
el.classList.add(offline > 0 ? 'server-status-warn' : 'server-status-error');
}
const offlineText = total === 0
? '<span class="offline-list">暂无状态数据</span>'
: offline > 0
? `<span class="offline-list">离线:${escapeHtml((status.offlineNames || []).join('、'))}</span>`
: '<span>全部在线</span>';
el.innerHTML = `
<span class="status-dot"></span>
<span>服务器在线状态</span>
<strong>在线 ${online}/${total}</strong>
${offlineText}
${buildLogText()}
`;
}
function openModal() {
buildModalShell();
document.getElementById('tms-helper-host').classList.add('show');
}
function closeModal() {
const host = document.getElementById('tms-helper-host');
if (host) host.classList.remove('show');
}
function buildSortableTable(rows) {
if (!rows.length) return `<div class="empty">暂无数据</div>`;
const columns = [
{ key: 'assertName', label: '影片名称', type: 'text', minWidth: 160 },
{ key: 'hallsCircled', label: '所在影厅', type: 'text', minWidth: 120, cellClass: 'halls', sortValueKey: 'hallsSortKey' },
{ key: 'duration', label: '时长(分钟)', type: 'number', minWidth: 80 },
{ key: 'contentExplained', label: '文件名', type: 'text', minWidth: 260, cellClass: 'content', sortValueKey: 'contentName' },
];
const head = `<thead><tr>${columns.map((c, i) => `
<th class="sortable col-${c.key}" data-col="${i}" data-type="${c.type}" style="min-width:${c.minWidth}px;">
${escapeHtml(c.label)}<span class="sort-arrow">⇅</span>
</th>
`).join('')}</tr></thead>`;
const body = rows.map((r, idx) => {
const tds = columns.map(c => {
const raw = r[c.key];
const sortVal = c.sortValueKey ? r[c.sortValueKey] : raw;
const classes = [`col-${c.key}`];
if (c.cellClass) classes.push(c.cellClass);
const cls = ` class="${classes.join(' ')}"`;
const display = raw == null ? '' : String(raw);
return `<td${cls} data-sort="${escapeHtml(sortVal == null ? '' : String(sortVal))}" title="${escapeHtml(display)}">${escapeHtml(display)}</td>`;
}).join('');
const searchText = columns
.map(c => r[c.key])
.concat([r.contentName, r.hallsSortKey])
.map(v => String(v == null ? '' : v).toLowerCase())
.join(' ');
return `<tr data-original-order="${idx}" data-search="${escapeHtml(searchText)}">${tds}</tr>`;
}).join('');
return `<div class="table-wrap"><table class="tbl">${head}<tbody>${body}</tbody></table></div>`;
}
function attachSortHandlers(rootEl) {
rootEl.querySelectorAll('table.tbl').forEach(table => {
const ths = table.querySelectorAll('thead th.sortable');
ths.forEach(th => {
th.addEventListener('click', () => {
const colIdx = parseInt(th.dataset.col, 10);
const type = th.dataset.type || 'text';
const current = th.classList.contains('sort-asc') ? 'asc'
: th.classList.contains('sort-desc') ? 'desc' : 'none';
const next = current === 'none' ? 'asc' : current === 'asc' ? 'desc' : 'none';
ths.forEach(other => {
other.classList.remove('sort-asc', 'sort-desc');
const arrow = other.querySelector('.sort-arrow');
if (arrow) arrow.textContent = '⇅';
});
if (next !== 'none') {
th.classList.add(next === 'asc' ? 'sort-asc' : 'sort-desc');
const arrow = th.querySelector('.sort-arrow');
if (arrow) arrow.textContent = next === 'asc' ? '↑' : '↓';
}
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
if (next === 'none') {
rows.sort((a, b) => parseInt(a.dataset.originalOrder, 10) - parseInt(b.dataset.originalOrder, 10));
} else {
const dir = next === 'asc' ? 1 : -1;
rows.sort((a, b) => {
const av = a.children[colIdx].dataset.sort || '';
const bv = b.children[colIdx].dataset.sort || '';
if (type === 'number') {
const an = parseFloat(av);
const bn = parseFloat(bv);
const aEmpty = isNaN(an) ? 1 : 0;
const bEmpty = isNaN(bn) ? 1 : 0;
if (aEmpty !== bEmpty) return aEmpty - bEmpty;
if (aEmpty) return 0;
return (an - bn) * dir;
}
return av.localeCompare(bv, 'zh-Hans-CN', { numeric: true }) * dir;
});
}
rows.forEach(r => tbody.appendChild(r));
});
});
});
}
function attachHallSearchHandlers(hallPane) {
const input = hallPane.querySelector('[data-action="hall-search"]');
const countEl = hallPane.querySelector('[data-id="hall-search-count"]');
if (!input) return;
const applySearch = () => {
const query = String(input.value || '').trim().toLowerCase();
let visibleSections = 0;
let visibleRowsTotal = 0;
hallPane.querySelectorAll('.hall-section').forEach(section => {
const sectionText = String(section.dataset.search || '').toLowerCase();
const hallMatched = query && sectionText.indexOf(query) !== -1;
const rows = Array.from(section.querySelectorAll('tbody tr'));
let visibleRows = 0;
rows.forEach(row => {
const rowText = String(row.dataset.search || row.textContent || '').toLowerCase();
const matched = !query || hallMatched || rowText.indexOf(query) !== -1;
row.style.display = matched ? '' : 'none';
if (matched) visibleRows += 1;
});
section.style.display = (!query || visibleRows > 0) ? '' : 'none';
if (query && visibleRows > 0) section.classList.add('open');
const count = section.querySelector('.hall-count');
if (count) {
count.textContent = query ? `${visibleRows}/${rows.length} 个文件` : `${rows.length} 个文件`;
}
if (!query || visibleRows > 0) visibleSections += 1;
visibleRowsTotal += visibleRows;
});
if (countEl) {
countEl.textContent = query ? `匹配 ${visibleSections} 个影厅 / ${visibleRowsTotal} 个文件` : '';
}
};
input.addEventListener('input', applySearch);
}
function buildRow(item, details, contentName) {
const halls = (details ? details.halls : item.halls) || [];
const assertName = details ? details.assert_name : item.assert_name;
const playTime = details ? details.play_time : item.play_time;
const cn = contentName != null ? contentName : item.content_name;
const hallDisplays = uniqueSortedText(halls.map(formatHallDisplay));
const hallsCircled = hallDisplays.join(' ');
return {
assertName: assertName || '',
hallsCircled,
hallsSortKey: hallDisplays.join(','),
duration: formatPlayTime(playTime),
contentName: cn || '',
contentExplained: formatContentNameWithExplanation(cn || ''),
};
}
function renderResult(processed) {
const tabsEl = $shadow('[data-id="tabs"]');
const bodyEl = $shadow('[data-id="body"]');
const metaEl = $shadow('[data-id="meta"]');
tabsEl.innerHTML = '';
bodyEl.innerHTML = '';
const movieRows = processed.movies.map(item => buildRow(item));
const hallNames = Object.keys(processed.halls);
const stats = processed.stats || {};
const hallEmptyHtml = `
<div class="empty">
暂无影厅数据。接口返回 ${stats.rawRows || 0} 条记录,其中 ${stats.rowsWithHall || 0} 条识别到影厅字段。
</div>
`;
const hallSectionsHtml = hallNames.map((name, idx) => {
const items = processed.halls[name];
const rows = items.map(it => buildRow(it, it.details, it.content_name));
const tableHtml = buildSortableTable(rows);
return `
<div class="hall-section open" data-hall-idx="${idx}" data-search="${escapeHtml(String(name).toLowerCase())}">
<div class="hall-summary">
<span class="hall-summary-left">
<span class="hall-toggle">▶</span>
<span class="hall-badge">${escapeHtml(formatHallDisplay(name) || `${idx + 1}`)}</span>
</span>
<span class="hall-count">${rows.length} 个文件</span>
</div>
<div class="hall-body">${tableHtml}</div>
</div>
`;
}).join('');
const tabs = [];
tabs.push({
key: 'view-hall',
label: `按影厅分类 (${hallNames.length})`,
html: `
<h2 class="section-title">🏢 按影厅分类展示影片内容</h2>
<div class="toolbar">
<button type="button" class="btn" data-action="expand-all">展开全部</button>
<button type="button" class="btn" data-action="collapse-all">折叠全部</button>
<input class="search-input" type="search" data-action="hall-search" placeholder="搜索影厅、影片或文件名">
<span class="search-count" data-id="hall-search-count"></span>
<span class="summary" style="margin:0;">共 ${hallNames.length} 个影厅,每个影厅独立展示。接口 ${stats.rawRows || 0} 条,识别影厅 ${stats.rowsWithHall || 0} 条。</span>
</div>
<div class="hall-list">${hallSectionsHtml || hallEmptyHtml}</div>
`,
});
tabs.push({
key: 'view-movie',
label: `按影片汇总 (${movieRows.length})`,
html: `
<h2 class="section-title">🎥 按影片查看所在影厅</h2>
<div class="toolbar">
<span class="summary" style="margin:0;">共 ${movieRows.length} 部影片,点击表头可排序。</span>
</div>
${buildSortableTable(movieRows)}
`,
});
tabs.forEach((t, i) => {
const tab = document.createElement('div');
tab.className = 'tab' + (i === 0 ? ' active' : '');
tab.dataset.target = t.key;
tab.textContent = t.label;
tab.addEventListener('click', () => activateTab(t.key));
tabsEl.appendChild(tab);
const pane = document.createElement('div');
pane.className = 'pane';
pane.dataset.pane = t.key;
pane.innerHTML = t.html;
bodyEl.appendChild(pane);
});
attachSortHandlers(bodyEl);
const hallPane = bodyEl.querySelector('[data-pane="view-hall"]');
if (hallPane) {
hallPane.querySelectorAll('button[data-action]').forEach(btn => {
btn.addEventListener('click', () => {
const open = btn.dataset.action === 'expand-all';
hallPane.querySelectorAll('.hall-section').forEach(d => {
if (d.style.display !== 'none') d.classList.toggle('open', open);
});
});
});
hallPane.querySelectorAll('.hall-section').forEach(section => {
const summary = section.querySelector('.hall-summary');
if (!summary) return;
summary.addEventListener('click', () => section.classList.toggle('open'));
});
attachHallSearchHandlers(hallPane);
}
if (metaEl) {
metaEl.textContent = `THEATER_ID=${readTheaterId() || '-'} · 共 ${movieRows.length} 部影片 / ${hallNames.length} 个影厅 · 原始 ${stats.rawRows || 0} 条`;
}
function activateTab(targetKey) {
tabsEl.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.target === targetKey));
const pane = Array.from(bodyEl.children)
.filter(p => p.classList.contains('pane'))
.find(p => p.dataset.pane === targetKey);
if (pane) pane.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
function showError(msg) {
const bodyEl = $shadow('[data-id="body"]');
const tabsEl = $shadow('[data-id="tabs"]');
if (tabsEl) tabsEl.innerHTML = '';
if (bodyEl) bodyEl.innerHTML = `<div class="err">❌ ${escapeHtml(msg)}</div>`;
}
async function onQueryClick() {
const btn = document.getElementById('tms-helper-fab');
if (btn) { btn.disabled = true; }
openModal();
const bodyEl = $shadow('[data-id="body"]');
const tabsEl = $shadow('[data-id="tabs"]');
if (tabsEl) tabsEl.innerHTML = '';
renderServerStatus({ loading: true });
if (bodyEl) bodyEl.innerHTML = `<div class="summary" style="padding:18px;">正在请求 TMS 接口,请稍候...</div>`;
try {
const [all, hallStatus, logStatus] = await Promise.all([
fetchDcpList(),
fetchHallStatus().catch(err => ({ error: err && err.message ? err.message : String(err) })),
fetchTmsLogRefreshTimes()
.then(times => ({ logTimes: times }))
.catch(err => ({ logError: err && err.message ? err.message : String(err) })),
]);
renderServerStatus({ ...hallStatus, ...logStatus });
if (!all.length) {
showError('接口返回为空,请确认当前影院 / 服务器是否有内容。');
return;
}
const processed = processMovies(all);
console.info('[TMS Helper] 查询结果', {
rawRows: processed.stats.rawRows,
contentRows: processed.stats.contentRows,
rowsWithHall: processed.stats.rowsWithHall,
duplicateContentNames: processed.stats.duplicateContentNames,
hallNames: Object.keys(processed.halls),
sample: all.slice(0, 3),
});
renderResult(processed);
} catch (err) {
console.error('[TMS Helper]', err);
renderServerStatus({ error: '内容查询失败,在线状态未刷新' });
showError(err && err.message ? err.message : String(err));
} finally {
if (btn) { btn.disabled = false; }
}
}
// ============================================================
// 8. 启动
// ============================================================
function init() {
if (document.body) {
ensureFab();
} else {
document.addEventListener('DOMContentLoaded', ensureFab, { once: true });
}
// body 可能在 SPA 里晚于 DOMContentLoaded 出现,做一次轮询兜底
let attempts = 0;
const timer = setInterval(() => {
attempts++;
if (document.body) {
ensureFab();
if (document.getElementById('tms-helper-fab') || attempts > 20) {
clearInterval(timer);
}
} else if (attempts > 20) {
clearInterval(timer);
}
}, 500);
}
init();
})();