// ==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 = ` `; 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 = `
❌ ${escapeHtml(msg)}
`; } function renderResult(payload) { const root = $shadow('[data-id="result"]'); if (!root) return; const total = payload.box + payload.goods; root.innerHTML = `
${escapeHtml(payload.text)}
营业日:${escapeHtml(payload.dateStr)} |  卖品统计区间:${escapeHtml(payload.startTs)} ~ ${escapeHtml(payload.endTs)} |  场次数:${payload.showCount}
观影人次
${payload.attendance.toLocaleString()}
今日票房
¥ ${fmtMoney(payload.box)}
卖品收入
¥ ${fmtMoney(payload.goods)}
合计 / 卖品占比
¥ ${fmtMoney(total)} · ${(total > 0 ? (payload.goods / total) * 100 : 0).toFixed(2)}%
`; 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(); })();