// ==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 = `