Spaces:
Paused
Paused
| // ==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, '>') | |
| .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 = ` | |
| <div class="modal" role="dialog" aria-modal="true"> | |
| <div class="header"> | |
| <div> | |
| <div class="title">📊 当日经营摘要</div> | |
| </div> | |
| <button class="close" type="button" data-id="close">关闭 ✕</button> | |
| </div> | |
| <div class="body" data-id="body"> | |
| <div class="row"> | |
| <label>店名</label> | |
| <input type="text" data-id="shop" placeholder="留空将自动读取当前账号所属影城" /> | |
| </div> | |
| <div class="row"> | |
| <label>查询日期</label> | |
| <input type="date" data-id="date" /> | |
| </div> | |
| <details class="adv" data-id="adv"> | |
| <summary> | |
| <span>🔐 票务系统账号(用于查询票房与人次,首次填写后自动记住)</span> | |
| <span style="margin-left:auto; display:inline-flex; gap:6px;"> | |
| <button type="button" class="btn btn-ghost btn-mini" data-id="relogin">重新登录</button> | |
| <button type="button" class="btn btn-ghost btn-mini" data-id="clear-token">清除登录状态</button> | |
| </span> | |
| </summary> | |
| <div class="adv-hint"> | |
| 首次使用请填写票务系统(鼎新报表)账号;脚本会自动登录并保存登录状态,账号过期会自动重新登录,无需重复操作。<br> | |
| 账号信息只保存在你当前浏览器的本地存储中,不会上传到任何外部服务器。换电脑或重装浏览器后需要重新填写。 | |
| </div> | |
| <form data-id="bi-form" autocomplete="on" onsubmit="return false;"> | |
| <div class="row"> | |
| <label>用户名</label> | |
| <input type="text" data-id="bi-username" name="username" autocomplete="username" placeholder="票务系统登录账号" /> | |
| </div> | |
| <div class="row"> | |
| <label>密码</label> | |
| <input type="password" data-id="bi-password" name="password" autocomplete="current-password" placeholder="票务系统登录密码" /> | |
| </div> | |
| <div class="row"> | |
| <label>影院编码</label> | |
| <input type="text" data-id="bi-res-code" name="rescode" autocomplete="off" placeholder="影院编码(8位数字)" /> | |
| </div> | |
| </form> | |
| </details> | |
| <div class="row"> | |
| <button type="button" class="btn btn-primary" data-id="run">🚀 查询</button> | |
| <button type="button" class="btn btn-ghost" data-id="set-today">回到营业日</button> | |
| <span class="hint" data-id="status"></span> | |
| </div> | |
| <div class="hint"> | |
| 营业日按当日 06:00 至次日 06:00 计算;当前时间在凌晨 06:00 之前,会自动归入前一天。 | |
| </div> | |
| <div data-id="result"></div> | |
| </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' && document.getElementById('hd-summary-host').classList.contains('show')) closeModal(); | |
| }); | |
| // 回填 | |
| const shopInput = SHADOW.querySelector('[data-id="shop"]'); | |
| const dateInput = SHADOW.querySelector('[data-id="date"]'); | |
| const userIn = SHADOW.querySelector('[data-id="bi-username"]'); | |
| const pwdIn = SHADOW.querySelector('[data-id="bi-password"]'); | |
| const resCodeIn = SHADOW.querySelector('[data-id="bi-res-code"]'); | |
| const advBox = SHADOW.querySelector('[data-id="adv"]'); | |
| shopInput.value = safeGet(STORE_KEYS.SHOP_NAME) || ''; | |
| dateInput.value = defaultBusinessDate(); | |
| userIn.value = safeGet(STORE_KEYS.BI_USERNAME) || ''; | |
| pwdIn.value = safeGet(STORE_KEYS.BI_PASSWORD) || ''; | |
| resCodeIn.value = safeGet(STORE_KEYS.BI_RES_CODE) || ''; | |
| // 没配过登录凭证时自动展开 | |
| if (!userIn.value || !pwdIn.value || !resCodeIn.value) advBox.open = true; | |
| SHADOW.querySelector('[data-id="run"]').addEventListener('click', onRunClick); | |
| SHADOW.querySelector('[data-id="set-today"]').addEventListener('click', () => { | |
| dateInput.value = defaultBusinessDate(); | |
| }); | |
| SHADOW.querySelector('[data-id="clear-token"]').addEventListener('click', (ev) => { | |
| ev.preventDefault(); | |
| try { localStorage.removeItem(STORE_KEYS.BI_TOKEN_CACHE); } catch (e) { /* noop */ } | |
| setStatus('已清除登录状态,下次查询会自动重新登录。', 'ok'); | |
| setTimeout(() => setStatus(''), 2500); | |
| }); | |
| SHADOW.querySelector('[data-id="relogin"]').addEventListener('click', async (ev) => { | |
| ev.preventDefault(); | |
| // 把当前输入持久化后立即重登一次 | |
| saveCredsFromInputs(); | |
| setStatus('正在登录...'); | |
| try { | |
| await biLogin(); | |
| setStatus('登录成功 ✅', 'ok'); | |
| setTimeout(() => setStatus(''), 2500); | |
| } catch (err) { | |
| setStatus('登录失败:' + (err && err.message || err), 'err'); | |
| } | |
| }); | |
| } | |
| function $shadow(sel) { return SHADOW ? SHADOW.querySelector(sel) : null; } | |
| function openModal() { | |
| buildModalShell(); | |
| // 每次打开都把日期框刷新成当日营业日,避免显示上次打开时的旧日期 | |
| const dateInput = $shadow('[data-id="date"]'); | |
| if (dateInput) dateInput.value = defaultBusinessDate(); | |
| document.getElementById('hd-summary-host').classList.add('show'); | |
| } | |
| function closeModal() { const h = document.getElementById('hd-summary-host'); if (h) h.classList.remove('show'); } | |
| function setStatus(msg, kind) { | |
| const el = $shadow('[data-id="status"]'); | |
| if (!el) return; | |
| el.textContent = msg || ''; | |
| el.style.color = kind === 'err' ? '#b91c1c' : kind === 'ok' ? '#16a34a' : '#64748b'; | |
| } | |
| function renderError(msg) { | |
| const root = $shadow('[data-id="result"]'); | |
| if (root) root.innerHTML = `<div class="err">❌ ${escapeHtml(msg)}</div>`; | |
| } | |
| function renderResult(payload) { | |
| const root = $shadow('[data-id="result"]'); | |
| if (!root) return; | |
| const total = payload.box + payload.goods; | |
| root.innerHTML = ` | |
| <div class="summary-card"> | |
| <div class="summary-text">${escapeHtml(payload.text)}</div> | |
| <div class="summary-meta"> | |
| 营业日:${escapeHtml(payload.dateStr)} | | |
| 卖品统计区间:${escapeHtml(payload.startTs)} ~ ${escapeHtml(payload.endTs)} | | |
| 场次数:${payload.showCount} | |
| </div> | |
| <div class="copy-area"> | |
| <textarea class="copy-input" data-id="copy-text" rows="2">${escapeHtml(payload.text)}</textarea> | |
| <button type="button" class="btn btn-primary" data-id="copy-btn">📋 复制</button> | |
| </div> | |
| </div> | |
| <div class="grid"> | |
| <div class="stat"><div class="k">观影人次</div><div class="v">${payload.attendance.toLocaleString()}</div></div> | |
| <div class="stat"><div class="k">今日票房</div><div class="v">¥ ${fmtMoney(payload.box)}</div></div> | |
| <div class="stat"><div class="k">卖品收入</div><div class="v">¥ ${fmtMoney(payload.goods)}</div></div> | |
| <div class="stat"><div class="k">合计 / 卖品占比</div><div class="v">¥ ${fmtMoney(total)} · ${(total > 0 ? (payload.goods / total) * 100 : 0).toFixed(2)}%</div></div> | |
| </div> | |
| `; | |
| root.querySelector('[data-id="copy-btn"]').addEventListener('click', () => { | |
| const ta = root.querySelector('[data-id="copy-text"]'); | |
| const value = ta ? ta.value : payload.text; | |
| try { | |
| if (typeof GM_setClipboard === 'function') { | |
| GM_setClipboard(value, { type: 'text', mimetype: 'text/plain' }); | |
| } else if (navigator.clipboard && navigator.clipboard.writeText) { | |
| navigator.clipboard.writeText(value); | |
| } else { | |
| ta.select(); document.execCommand('copy'); | |
| } | |
| setStatus('已复制到剪贴板 ✅', 'ok'); | |
| setTimeout(() => setStatus(''), 2500); | |
| } catch (e) { | |
| setStatus('复制失败,请手动选中文本复制。', 'err'); | |
| } | |
| }); | |
| } | |
| function saveCredsFromInputs() { | |
| const userIn = $shadow('[data-id="bi-username"]'); | |
| const pwdIn = $shadow('[data-id="bi-password"]'); | |
| const resCodeIn = $shadow('[data-id="bi-res-code"]'); | |
| if (!userIn) return; | |
| safeSet(STORE_KEYS.BI_USERNAME , userIn.value || ''); | |
| safeSet(STORE_KEYS.BI_PASSWORD , pwdIn.value || ''); | |
| safeSet(STORE_KEYS.BI_RES_CODE , resCodeIn.value || ''); | |
| } | |
| async function onRunClick() { | |
| const runBtn = $shadow('[data-id="run"]'); | |
| const shopInput = $shadow('[data-id="shop"]'); | |
| const dateInput = $shadow('[data-id="date"]'); | |
| const result = $shadow('[data-id="result"]'); | |
| if (result) result.innerHTML = ''; | |
| let shop = (shopInput.value || '').trim(); | |
| const dateStr = dateInput.value || defaultBusinessDate(); | |
| // 持久化店名 / BI 凭证(日期不持久化,避免下次打开仍显示旧日期) | |
| safeSet(STORE_KEYS.SHOP_NAME, shop); | |
| saveCredsFromInputs(); | |
| const centerToken = readCenterToken(); | |
| if (!centerToken) { | |
| renderError('当前未检测到票务中心后台的登录状态。请先在本标签页登录票务中心后台后再查询。'); | |
| return; | |
| } | |
| runBtn.disabled = true; | |
| setStatus('查询中,请稍候...'); | |
| // 店名为空时自动读 useDomainName | |
| if (!shop) { | |
| try { | |
| const cinemaName = await fetchCinemaName(centerToken); | |
| if (cinemaName) { | |
| shop = cinemaName; | |
| shopInput.value = cinemaName; | |
| safeSet(STORE_KEYS.SHOP_NAME, cinemaName); | |
| } | |
| } catch (e) { /* 忽略,用兜底 */ } | |
| } | |
| // 卖品营业日窗口:当日 06:00 ~ 次日 06:00 | |
| const startTs = `${dateStr} 06:00:00`; | |
| const endTs = `${nextDayStr(dateStr)} 06:00:00`; | |
| try { | |
| const [goods, shows] = await Promise.all([ | |
| fetchGoodsSalesSummary(centerToken, startTs, endTs).catch(err => { throw new Error('查询卖品收入失败:' + (err && err.message || err)); }), | |
| fetchScheduleAttendance(dateStr).catch(err => { throw new Error('查询场次(用于统计人次/票房)失败:' + (err && err.message || err)); }), | |
| ]); | |
| // 人次 & 票房:累加每场的 soldTicketNum / soldBoxOffice,与排片接口实时同步 | |
| // (旧版从 BI incomeProportion.action 取 ticketIncome,是 T+1 聚合数据,当天查会返回 0) | |
| let attendance = 0; | |
| let boxOffice = 0; | |
| for (const r of shows || []) { | |
| attendance += toNum(r.soldTicketNum); | |
| boxOffice += toNum(r.soldBoxOffice); | |
| } | |
| attendance = Math.round(attendance); | |
| const goodsAmount = goods.realAmount || 0; | |
| const text = buildSummaryText(shop, attendance, boxOffice, goodsAmount); | |
| renderResult({ | |
| text, dateStr, startTs, endTs, | |
| attendance, | |
| box: boxOffice, | |
| goods: goodsAmount, | |
| showCount: shows.length, | |
| }); | |
| setStatus('查询完成 ✅', 'ok'); | |
| } catch (err) { | |
| console.error('[HD Summary]', err); | |
| renderError(err && err.message ? err.message : String(err)); | |
| setStatus('查询失败', 'err'); | |
| } finally { | |
| runBtn.disabled = false; | |
| } | |
| } | |
| // ============================================================ | |
| // 5. 浮动按钮 + 拖动 | |
| // ============================================================ | |
| function clamp(v, mn, mx) { return Math.min(Math.max(v, mn), mx); } | |
| 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 nl = clamp(left, margin, maxLeft); | |
| const nt = clamp(top, margin, maxTop); | |
| btn.style.left = `${nl}px`; | |
| btn.style.top = `${nt}px`; | |
| btn.style.right = 'auto'; | |
| btn.style.bottom = 'auto'; | |
| safeSet(STORE_KEYS.FAB_POS, JSON.stringify({ left: nl, top: nt })); | |
| } | |
| function restoreFabPos(btn) { | |
| const raw = safeGet(STORE_KEYS.FAB_POS); | |
| if (!raw) return; | |
| try { | |
| const pos = JSON.parse(raw); | |
| if (Number.isFinite(pos.left) && Number.isFinite(pos.top)) { | |
| requestAnimationFrame(() => placeFab(btn, pos.left, pos.top)); | |
| } | |
| } catch (e) { /* noop */ } | |
| } | |
| function makeDraggable(btn) { | |
| let drag = null; | |
| let suppressClick = false; | |
| btn.addEventListener('pointerdown', (ev) => { | |
| if (ev.button !== 0 || btn.disabled) return; | |
| const rect = btn.getBoundingClientRect(); | |
| drag = { | |
| pid: ev.pointerId, | |
| sx: ev.clientX, sy: ev.clientY, | |
| ox: ev.clientX - rect.left, oy: ev.clientY - rect.top, | |
| moved: false, | |
| }; | |
| try { btn.setPointerCapture(ev.pointerId); } catch (e) { /* noop */ } | |
| }); | |
| btn.addEventListener('pointermove', (ev) => { | |
| if (!drag || ev.pointerId !== drag.pid) return; | |
| const dx = ev.clientX - drag.sx; | |
| const dy = ev.clientY - drag.sy; | |
| if (!drag.moved && Math.hypot(dx, dy) < 5) return; | |
| drag.moved = true; | |
| suppressClick = true; | |
| btn.classList.add('hd-dragging'); | |
| placeFab(btn, ev.clientX - drag.ox, ev.clientY - drag.oy); | |
| ev.preventDefault(); | |
| }); | |
| function end(ev) { | |
| if (!drag || ev.pointerId !== drag.pid) return; | |
| try { btn.releasePointerCapture(ev.pointerId); } catch (e) { /* noop */ } | |
| btn.classList.remove('hd-dragging'); | |
| drag = null; | |
| setTimeout(() => { suppressClick = false; }, 0); | |
| } | |
| btn.addEventListener('pointerup', end); | |
| btn.addEventListener('pointercancel', end); | |
| btn.addEventListener('click', (ev) => { | |
| if (suppressClick) { ev.preventDefault(); ev.stopImmediatePropagation(); return; } | |
| openModal(); | |
| }); | |
| 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('hd-summary-fab')) return; | |
| if (!document.body) return; | |
| const btn = document.createElement('button'); | |
| btn.id = 'hd-summary-fab'; | |
| btn.type = 'button'; | |
| btn.textContent = '📊 当日经营摘要'; | |
| btn.title = '单击查询当日票房/人次/卖品摘要;按住可拖动'; | |
| makeDraggable(btn); | |
| document.body.appendChild(btn); | |
| restoreFabPos(btn); | |
| } | |
| function init() { | |
| if (document.body) ensureFab(); | |
| else document.addEventListener('DOMContentLoaded', ensureFab, { once: true }); | |
| let attempts = 0; | |
| const t = setInterval(() => { | |
| attempts++; | |
| if (document.body) { | |
| ensureFab(); | |
| if (document.getElementById('hd-summary-fab') || attempts > 20) clearInterval(t); | |
| } else if (attempts > 20) { clearInterval(t); } | |
| }, 500); | |
| } | |
| init(); | |
| })(); | |