hengdian / userscripts /center-daily-summary.user.js
Codex
Upload current Hugging Face Space snapshot
732907b
Raw
History Blame Contribute Delete
38 kB
// ==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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// 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();
})();