Spaces:
Paused
Paused
| // ==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, '>') | |
| .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 内容<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(); | |
| })(); | |