// ==UserScript== // @name 影城当日经营摘要 // @name:zh-CN 影城当日经营摘要 // @namespace https://center.hengdianfilm.com/ // @version 1.5.0 // @description 在票务中心后台一键查询当日票房、观影人次、卖品收入与卖品占比,并提供一键复制汇报话术,方便发群和填日报。 // @description:zh-CN 在票务中心后台一键查询当日票房、观影人次、卖品收入与卖品占比,并提供一键复制汇报话术,方便发群和填日报。 // @author pzt // @match *://center.hengdianfilm.com/* // @run-at document-idle // @grant GM_addStyle // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @connect app.bi.piao51.cn // @connect cawapi.yinghezhong.com // @license MIT // ==/UserScript== (function () { 'use strict'; // 仅顶层 frame 注入 UI let isTop = false; try { isTop = window.top === window; } catch (e) { isTop = true; } if (!isTop) return; // ============================================================ // 0. 常量 & 工具 // ============================================================ const CENTER_BASE = 'https://center.hengdianfilm.com'; const JMREPORT_URL = `${CENTER_BASE}/jimu/jmreport/show`; const USER_INFO_URL = `${CENTER_BASE}/admin/user/info`; const GOODS_SALES_REPORT_ID = '1153597763885797376'; // 售卖员报表 2 // 旧 BI(app.bi.piao51.cn)—— 仅用于登录拿 token,cawapi 的人次/票房接口共用此 token const BI_LOGIN_URL = 'https://app.bi.piao51.cn/cinema-app/credential/login.action'; const BI_LOGINED_URL = 'https://app.bi.piao51.cn/cinema-app/security/logined.action'; const BI_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; // 排片人次(cawapi.yinghezhong.com)—— 与 app.py::fetch_schedule_data 一致 // 注意:此接口用的 token 与 BI 不同,需要 cawapi 自己签的 token;浏览器 cookie 里就有 const CAW_SCHEDULE_URL = 'https://cawapi.yinghezhong.com/showInfo/getHallShowInfo'; const CAW_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36'; // 营业日凌晨 6:00 分界 const BUSINESS_CROSSOVER_HOUR = 6; const STORE_KEYS = { SHOP_NAME : 'hd_summary_shop_name', FAB_POS : 'hd_summary_fab_pos', QUERY_DATE: 'hd_summary_last_date', BI_USERNAME : 'hd_summary_bi_username', BI_PASSWORD : 'hd_summary_bi_password', BI_RES_CODE : 'hd_summary_bi_res_code', BI_TOKEN_CACHE: 'hd_summary_bi_token_cache', // {token, savedAt} }; function safeGet(key) { try { return localStorage.getItem(key) || ''; } catch (e) { return ''; } } function safeSet(key, val) { try { localStorage.setItem(key, String(val == null ? '' : val)); } catch (e) { /* noop */ } } function safeJsonGet(key) { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { return null; } } function safeJsonSet(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) { /* noop */ } } function readCookie(name) { const reg = new RegExp('(?:^|;\\s*)' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'); const match = document.cookie.match(reg); return match ? decodeURIComponent(match[1]) : ''; } function readCenterToken() { const fromCookie = readCookie('bruts_token'); if (fromCookie) return fromCookie; try { for (const k of Object.keys(localStorage)) { if (/token|auth/i.test(k)) { const v = localStorage.getItem(k) || ''; const stripped = v.replace(/^Bearer\s+/i, '').trim(); if (/^[a-z0-9-]{20,}$/i.test(stripped)) return stripped; } } } catch (e) { /* noop */ } return ''; } function pad2(n) { return String(n).padStart(2, '0'); } function fmtDate(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } // 营业日:当前时刻早于 06:00 视为前一天(与 app.py::get_default_business_date 一致) function defaultBusinessDate() { const now = new Date(); if (now.getHours() < BUSINESS_CROSSOVER_HOUR) { now.setDate(now.getDate() - 1); } return fmtDate(now); } function nextDayStr(yyyy_mm_dd) { const d = new Date(yyyy_mm_dd + 'T00:00:00'); d.setDate(d.getDate() + 1); return fmtDate(d); } function toNum(v) { if (v == null) return 0; const s = String(v).replace(/,/g, '').trim(); if (!s) return 0; const n = parseFloat(s); return Number.isFinite(n) ? n : 0; } function fmtMoney(v) { return Number(v || 0).toFixed(2); } function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // GM_xmlhttpRequest 包装为 Promise(用来跨域请求 app.bi.piao51.cn) function gmRequest(opts) { return new Promise((resolve, reject) => { try { GM_xmlhttpRequest({ timeout: 20000, ...opts, onload : (resp) => resolve(resp), onerror : (err) => reject(new Error(err && err.error ? err.error : '网络错误')), ontimeout: () => reject(new Error('请求超时')), onabort : () => reject(new Error('请求被中断')), }); } catch (e) { reject(e); } }); } function urlEncodeForm(obj) { return Object.keys(obj) .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(obj[k] == null ? '' : obj[k])}`) .join('&'); } // ============================================================ // 1. center 接口请求 // ============================================================ async function fetchCinemaName(token) { try { const resp = await fetch(USER_INFO_URL, { method: 'GET', credentials: 'include', headers: { 'Accept': 'application/json, text/plain, */*', 'Authorization': `Bearer ${token}`, 'TENANT-ID': '1', 'Channel': '4', }, }); if (!resp.ok) return ''; const data = await resp.json(); const root = data && data.data || {}; return String(root.useDomainName || '').trim(); } catch (e) { return ''; } } // 通过 BI Token 调用 cawapi 拉取当日场次列表(含 soldTicketNum) // 注意:cawapi 也是同一套 BI 系统签的 token,与 incomeProportion 共用 async function callCawScheduleOnce(biToken, dateStr) { const url = `${CAW_SCHEDULE_URL}?showDate=${encodeURIComponent(dateStr)}&token=${encodeURIComponent(biToken)}&_=${Date.now()}`; const resp = await gmRequest({ method: 'GET', url, headers: { 'Accept' : 'application/json, text/plain, */*', 'Origin' : 'https://caw.yinghezhong.com', 'Referer' : 'https://caw.yinghezhong.com/', 'User-Agent': CAW_USER_AGENT, }, }); if (resp.status < 200 || resp.status >= 300) { throw new Error(`查询场次接口出错(HTTP ${resp.status}),稍后再试。`); } let data; try { data = JSON.parse(resp.responseText || ''); } catch (e) { throw new Error('场次接口返回内容异常:' + (resp.responseText || '').slice(0, 200)); } // app.py: code==1 ok; code==500 token 失效 if (data.code === 500) { const err = new Error('登录状态已过期'); err.code = 'BI_TOKEN_EXPIRED'; throw err; } if (data.code !== 1) { throw new Error('场次接口业务失败:' + (data.msg || '未知错误')); } return Array.isArray(data.data) ? data.data : []; } async function fetchScheduleAttendance(dateStr) { const cache = await ensureBiToken(false); try { return await callCawScheduleOnce(cache.token, dateStr); } catch (err) { if (err && err.code === 'BI_TOKEN_EXPIRED') { const fresh = await biLogin(); return await callCawScheduleOnce(fresh.token, dateStr); } throw err; } } async function fetchGoodsSalesSummary(token, startTs, endTs) { const innerParams = { token, pageNo: 1, pageSize: 100, start: startTs, end: endTs, customTableTitleSorts: [], }; const body = { id: GOODS_SALES_REPORT_ID, apiUrl: '', jmRecordId: '', sheetId: '', params: JSON.stringify(innerParams), }; const resp = await fetch(JMREPORT_URL, { method: 'POST', credentials: 'include', headers: { 'Accept': 'application/json, text/plain, */*', 'Content-Type': 'application/json;charset=UTF-8', 'token': token, 'x-access-token': token, 'Referer': `${CENTER_BASE}/jimu/jmreport/shareView/${GOODS_SALES_REPORT_ID}?token=${encodeURIComponent(token)}`, }, body: JSON.stringify(body), }); if (!resp.ok) throw new Error(`卖品报表请求失败(HTTP ${resp.status})`); const data = await resp.json(); if (data.success === false) throw new Error(`卖品报表返回失败:${data.message || '未知错误'}`); const expData = ((data.result || {}).dataList || {}).expData || {}; const normalized = {}; for (const k of Object.keys(expData)) normalized[k.toLowerCase()] = expData[k]; return { realAmount : toNum(normalized['=dbsum(#{goods_sales_2.real_amount})']), salesAmount : toNum(normalized['=dbsum(#{goods_sales_2.sales_amount})']), salesNum : toNum(normalized['=dbsum(#{goods_sales_2.sales_num})']), }; } // ============================================================ // 2. 旧 BI(app.bi.piao51.cn)—— 4 凭证登录 + token 缓存 + 自动续期 // 与 app.py::login_and_get_token / fetch_income_data 一致 // ============================================================ function readBiCreds() { return { username: safeGet(STORE_KEYS.BI_USERNAME).trim(), password: safeGet(STORE_KEYS.BI_PASSWORD), // 保持原文,不 trim 末尾空格也无所谓但密码不应 trim resCode : safeGet(STORE_KEYS.BI_RES_CODE).trim(), }; } function biCredsValid(c) { return !!(c.username && c.password && c.resCode); } async function biLogin() { const creds = readBiCreds(); if (!biCredsValid(creds)) { throw new Error('请先在「票务系统账号」中填写用户名、密码和影院编码。'); } // Step 1: POST 登录表单(dtype=ios, type=1) const formBody = urlEncodeForm({ username: creds.username, password: creds.password, type: '1', resCode: creds.resCode, dtype: 'ios', }); await gmRequest({ method: 'POST', url: BI_LOGIN_URL, headers: { 'Accept': 'application/json, text/javascript, */*; q=0.01', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Origin' : 'https://app.bi.piao51.cn', 'User-Agent': BI_USER_AGENT, }, data: formBody, }); // 登录成功后浏览器会自动写入 token cookie 到 app.bi.piao51.cn // Step 2: GET logined.action 拿 data.token const resp = await gmRequest({ method: 'GET', url: BI_LOGINED_URL, headers: { 'Accept' : 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': BI_USER_AGENT, }, }); if (resp.status < 200 || resp.status >= 300) { throw new Error(`登录服务器返回错误(HTTP ${resp.status}),请检查账号或密码是否正确。`); } let info; try { info = JSON.parse(resp.responseText || ''); } catch (e) { throw new Error('登录返回内容无法解析,可能是账号或密码错误。原始响应:' + (resp.responseText || '').slice(0, 200)); } const data = info && info.data || {}; const token = String(data.token || '').trim(); if (!info.success || !token) { throw new Error('登录失败:' + (info.msg || '请检查账号信息是否正确')); } // 缓存到 localStorage const cache = { token, savedAt: Date.now(), cinemaId: data.cinemaId || creds.resCode }; safeJsonSet(STORE_KEYS.BI_TOKEN_CACHE, cache); return cache; } async function ensureBiToken(forceRefresh) { if (!forceRefresh) { const cache = safeJsonGet(STORE_KEYS.BI_TOKEN_CACHE); if (cache && cache.token) return cache; } return await biLogin(); } // ============================================================ // 3. 业务计算 // ============================================================ function buildSummaryText(shopName, attendance, boxOffice, goodsAmount) { const total = boxOffice + goodsAmount; const ratio = total > 0 ? (goodsAmount / total) * 100 : 0; const shop = (shopName || '').trim() || '本影城'; return `${shop},今日票房:${fmtMoney(boxOffice)}元,观影人次:${attendance},卖品收入:${fmtMoney(goodsAmount)}元,卖品占比:${ratio.toFixed(2)}%。`; } // ============================================================ // 4. UI // ============================================================ GM_addStyle(` #hd-summary-fab { position: fixed; right: 24px; bottom: 100px; z-index: 2147483646; background: linear-gradient(135deg,#D83B01,#FF7A18); color: #fff; border: none; border-radius: 999px; padding: 12px 18px; font-size: 14px; font-weight: 600; box-shadow: 0 8px 24px rgba(216,59,1,.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; } #hd-summary-fab:hover { transform: translateY(-2px); } #hd-summary-fab.hd-dragging { cursor: grabbing; } #hd-summary-fab[disabled] { opacity: .6; cursor: progress; } #hd-summary-host { position: fixed; inset: 0; z-index: 2147483647; display: none; } #hd-summary-host.show { display: block; } `); const SHADOW_CSS = ` :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.55; } .modal { background: #fff; width: min(92vw, 600px); max-height: 92vh; 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,#5C1F00,#D83B01); color: #fff; } .header .title { font-size: 16px; font-weight: 700; } .close { background: rgba(255,255,255,.18); color: #fff; border: none; border-radius: 6px; padding: 6px 10px; cursor: pointer; font-size: 13px; } .body { padding: 14px 20px; overflow: auto; flex: 1; background: #fff; } .row { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; } .row label { font-size: 13px; color: #475569; min-width: 96px; } .row input { flex: 1; min-width: 160px; height: 32px; padding: 4px 10px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 13px; outline: none; background: #fff; color: #0f172a; } .row input:focus { border-color: #D83B01; box-shadow: 0 0 0 2px rgba(216,59,1,.12); } .btn { padding: 7px 14px; font-size: 13px; cursor: pointer; border: 1px solid transparent; border-radius: 6px; font-weight: 600; } .btn-primary { background: #D83B01; color: #fff; } .btn-primary:hover { background: #b9320a; } .btn-primary[disabled] { background: #94a3b8; cursor: progress; } .btn-ghost { background: #fff; color: #334155; border-color: #cbd5e1; } .btn-ghost:hover { border-color: #D83B01; color: #D83B01; } .btn-mini { padding: 4px 10px; font-size: 12px; } details.adv { margin: 6px 0 12px; border: 1px dashed #cbd5e1; border-radius: 8px; padding: 6px 10px; background: #f8fafc; } details.adv > summary { cursor: pointer; font-size: 13px; color: #334155; font-weight: 600; padding: 4px 0; user-select: none; outline: none; list-style: none; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } details.adv > summary::-webkit-details-marker { display: none; } details.adv > summary::before { content: '▶ '; color: #94a3b8; font-size: 11px; } details.adv[open] > summary::before { content: '▼ '; } details.adv .adv-hint { font-size: 12px; color: #64748b; margin: 6px 0 8px; line-height: 1.6; } details.adv code { background: #fff; padding: 1px 4px; border-radius: 3px; border: 1px solid #e2e8f0; font-size: 12px; } .hint { font-size: 12px; color: #64748b; margin: 4px 0 12px; } .err { color: #b91c1c; background: #fef2f2; border: 1px solid #fecaca; padding: 10px 12px; border-radius: 6px; font-size: 13px; margin-top: 10px; white-space: pre-wrap; } .summary-card { margin-top: 14px; padding: 16px 18px; border-left: 6px solid #D83B01; background: #FFF4ED; border-radius: 4px; } .summary-text { font-size: 16px; font-weight: 700; color: #5C1F00; white-space: pre-wrap; word-break: break-all; } .summary-meta { font-size: 12px; color: #7c2d12; margin-top: 8px; } .copy-area { margin-top: 10px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } textarea.copy-input { flex: 1; min-width: 260px; min-height: 64px; border: 1px solid #fed7aa; border-radius: 6px; padding: 8px 10px; font-size: 13px; resize: vertical; background: #fff; color: #0f172a; font-family: inherit; outline: none; } .grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; margin-top: 12px; } .stat { border: 1px solid #fed7aa; border-radius: 8px; padding: 10px 12px; background: #FFFBF7; } .stat .k { font-size: 12px; color: #7c2d12; } .stat .v { font-size: 18px; font-weight: 800; color: #D83B01; margin-top: 2px; } `; let SHADOW = null; function buildModalShell() { if (SHADOW) return; const host = document.createElement('div'); host.id = 'hd-summary-host'; document.body.appendChild(host); SHADOW = host.attachShadow({ mode: 'open' }); const styleEl = document.createElement('style'); styleEl.textContent = SHADOW_CSS; SHADOW.appendChild(styleEl); const wrapper = document.createElement('div'); wrapper.className = 'mask'; wrapper.innerHTML = `