// ==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, '&') .replace(//g, '>') .replace(/"/g, '"'); } 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 内容等待登录态…'; 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 = ` `; 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 '日志刷新:获取失败'; } if (times.length) { return `日志刷新:${escapeHtml(times.join(' / '))}`; } return '日志刷新:暂无'; }; if (!status || status.loading) { el.innerHTML = '服务器在线状态:查询中...日志刷新:查询中...'; return; } if (status.error) { el.classList.add('server-status-error'); el.innerHTML = `服务器在线状态:获取失败${escapeHtml(status.error)}${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 ? '暂无状态数据' : offline > 0 ? `离线:${escapeHtml((status.offlineNames || []).join('、'))}` : '全部在线'; el.innerHTML = ` 服务器在线状态 在线 ${online}/${total} ${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 `
暂无数据
`; 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 = `${columns.map((c, i) => ` ${escapeHtml(c.label)} `).join('')}`; 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 `${escapeHtml(display)}`; }).join(''); const searchText = columns .map(c => r[c.key]) .concat([r.contentName, r.hallsSortKey]) .map(v => String(v == null ? '' : v).toLowerCase()) .join(' '); return `${tds}`; }).join(''); return `
${head}${body}
`; } 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 = `
暂无影厅数据。接口返回 ${stats.rawRows || 0} 条记录,其中 ${stats.rowsWithHall || 0} 条识别到影厅字段。
`; 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 `
${escapeHtml(formatHallDisplay(name) || `${idx + 1}`)} ${rows.length} 个文件
${tableHtml}
`; }).join(''); const tabs = []; tabs.push({ key: 'view-hall', label: `按影厅分类 (${hallNames.length})`, html: `

🏢 按影厅分类展示影片内容

共 ${hallNames.length} 个影厅,每个影厅独立展示。接口 ${stats.rawRows || 0} 条,识别影厅 ${stats.rowsWithHall || 0} 条。
${hallSectionsHtml || hallEmptyHtml}
`, }); tabs.push({ key: 'view-movie', label: `按影片汇总 (${movieRows.length})`, html: `

🎥 按影片查看所在影厅

共 ${movieRows.length} 部影片,点击表头可排序。
${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 = `
❌ ${escapeHtml(msg)}
`; } 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 = `
正在请求 TMS 接口,请稍候...
`; 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(); })();