// ==UserScript== // @name B站游戏开票助手 // @namespace https://github.com/dawn-lc/bili-invoice-helper // @version 1.4.1 // @description 批量申请 B 站游戏发票,支持游戏选择、扫描、逐单提交、开票记录、总金额统计 // @author dawn-lc // @match https://game.bilibili.com/kf/invoice/* // @icon https://www.bilibili.com/favicon.ico // @run-at document-idle // @grant none // @license MIT // ==/UserScript== (function () { "use strict"; // ============ 常量 ============ const BASE = "https://game.bilibili.com"; const PAGE_SIZE = 50; const QUERY_DELAY = 150; const QUERY_RETRIES = 2; const REQUEST_TIMEOUT = 30000; const CREATE_DELAY = 1000; const RECORD_PAGE_SIZE = 10; const SUM_PAGE_SIZE = 50; const STATUS_TODO = "未申请"; const MIN_START_DATE = "2019-01-01"; const LS_KEY = "bili_invoice_helper_v2"; const DEFAULT_GAME = { id: 97, name: "碧蓝航线" }; const headers = { Accept: "application/json, text/javascript, */*; q=0.01", "Content-Type": "application/json", "X-Requested-With": "XMLHttpRequest", }; function sleep(ms, signal) { if (signal?.aborted) { return Promise.reject(new DOMException("请求已取消", "AbortError")); } return new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { settled = true; signal?.removeEventListener("abort", onAbort); resolve(); }, ms); const onAbort = () => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener("abort", onAbort); reject(new DOMException("请求已取消", "AbortError")); }; signal?.addEventListener("abort", onAbort, { once: true }); }); } function formatError(error) { if (!error) return "未知错误"; if (error.name === "AbortError") return "请求已取消"; return error.message || String(error); } function moneyToCents(value) { const n = Number(value); return Number.isFinite(n) ? Math.round(n * 100) : 0; } function formatMoney(cents) { return `¥${(cents / 100).toFixed(2)}`; } // ============ 本地记忆 ============ function loadCfg() { try { return JSON.parse(localStorage.getItem(LS_KEY)) || {}; } catch { return {}; } } function saveCfg(cfg) { try { localStorage.setItem(LS_KEY, JSON.stringify(cfg)); } catch { } } const _cfg = loadCfg(); // ============ 状态 ============ const state = { orders: new Map(), selected: new Set(), scanning: false, invoicing: false, stopFlag: false, requestController: null, recordDirty: true, // 不再填默认值,用户必须自行填写 invoiceTitle: _cfg.title || "", invoiceEmail: _cfg.email || "", game: _cfg.game || { ...DEFAULT_GAME }, gameList: [], recordList: [], recordPage: 1, recordTotal: 0, view: "invoice", }; // ============ 工具 ============ function fmtDate(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function enumerateMonths(startYM, endYM) { const [sy, sm] = startYM.split("-").map(Number); const [ey, em] = endYM.split("-").map(Number); const out = []; let y = sy; let m = sm; while (y < ey || (y === ey && m <= em)) { out.push(`${y}-${String(m).padStart(2, "0")}`); m++; if (m > 12) { m = 1; y++; } } return out; } function isValidEmail(s) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); } function payMonthOf(date) { const match = /^(\d{4})-(\d{2})/.exec(String(date ?? "")); return match ? `${match[1]}-${match[2]}` : ""; } // ============ API ============ async function apiFetch(path, body, { signal, retries = 0 } = {}) { let attempt = 0; while (true) { if (signal?.aborted) { throw new DOMException("请求已取消", "AbortError"); } const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); }, REQUEST_TIMEOUT); const onAbort = () => controller.abort(); if (signal) { signal.addEventListener("abort", onAbort, { once: true }); } try { const res = await fetch(`${BASE}${path}`, { credentials: "include", headers, referrer: `${BASE}/kf/invoice/`, method: "POST", body: JSON.stringify(body), signal: controller.signal, }); const text = await res.text(); if (!res.ok) { throw new Error( `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`, ); } try { return JSON.parse(text); } catch { const preview = text.replace(/\s+/g, " ").slice(0, 80); throw new Error(`响应不是 JSON:${preview || "空响应"}`); } } catch (e) { if (signal?.aborted) { throw new DOMException("请求已取消", "AbortError"); } if (controller.signal.aborted && attempt < retries) { attempt++; await sleep(500 * 2 ** (attempt - 1), signal); continue; } if (attempt < retries && e.name !== "AbortError") { attempt++; await sleep(500 * 2 ** (attempt - 1), signal); continue; } if (controller.signal.aborted) { throw new Error(`请求超时(${REQUEST_TIMEOUT / 1000}s)`); } throw e; } finally { clearTimeout(timeout); if (signal) { signal.removeEventListener("abort", onAbort); } } } } async function apiQuery(payMonth, pageNo, signal) { return apiFetch( "/api/invoice/order/query", { game_base_id: state.game.id, pay_month: payMonth, page_no: pageNo, page_size: PAGE_SIZE, }, { signal, retries: QUERY_RETRIES, }, ); } async function apiCreate(order, title, email, signal) { return apiFetch( "/api/invoice/create", { game_base_id: state.game.id, enterprise_type: 0, title, taxpayer_no: "", address: "", phone: "", bank_address: "", bank_account: "", email, order_details: [ { order_no: order.order_no, pay_date: order.pay_date, }, ], }, { signal, }, ); } async function apiGameList(signal) { return apiFetch( "/api/invoice/all/game/query", {}, { signal, retries: QUERY_RETRIES, }, ); } async function apiRecordQuery(pageNo, pageSize, signal) { return apiFetch( "/api/invoice/record/query", { page_no: pageNo, page_size: pageSize, }, { signal, retries: QUERY_RETRIES, }, ); } // ============ 注入 UI ============ const host = document.createElement("div"); host.id = "bili-invoice-helper"; document.body.appendChild(host); const shadow = host.attachShadow({ mode: "open", }); const style = document.createElement("style"); style.textContent = ` * { box-sizing: border-box; margin: 0; padding: 0; } .float-btn { position: fixed; right: 24px; bottom: 24px; background: linear-gradient(135deg, #00aeec, #0088cc); color: #fff; padding: 12px 20px; border-radius: 24px; cursor: pointer; user-select: none; box-shadow: 0 6px 20px rgba(0, 174, 236, 0.4); font-size: 14px; font-weight: 600; font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; z-index: 2147483647; transition: transform 0.15s, box-shadow 0.15s; } .float-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,174,236,0.55); } .panel { position: fixed; right: 24px; bottom: 80px; width: 460px; max-height: 82vh; background: #fff; border-radius: 14px; box-shadow: 0 12px 40px rgba(0,0,0,0.18); z-index: 2147483646; display: flex; flex-direction: column; font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; font-size: 13px; color: #333; overflow: hidden; } .panel.hidden { display: none !important; } .header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: linear-gradient(135deg, #00aeec, #0088cc); color: #fff; } .header .title { font-weight: 600; font-size: 15px; } .header .close { cursor: pointer; font-size: 22px; line-height: 1; padding: 0 4px; opacity: 0.85; } .header .close:hover { opacity: 1; } .tabs { display: flex; background: #f5f7fa; border-bottom: 1px solid #e8e8e8; } .tabs .tab { flex: 1; text-align: center; padding: 10px; cursor: pointer; font-size: 13px; color: #666; border-bottom: 2px solid transparent; transition: all 0.15s; } .tabs .tab:hover { color: #00aeec; } .tabs .tab.active { color: #00aeec; border-bottom-color: #00aeec; font-weight: 600; background: #fff; } .body { padding: 16px 18px; overflow-y: auto; flex: 1; } .body::-webkit-scrollbar { width: 6px; } .body::-webkit-scrollbar-thumb { background: #ccc; border-radius: 3px; } .section { margin-bottom: 16px; } .section-title { font-weight: 600; font-size: 12px; color: #888; letter-spacing: 0.5px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; } .section-title .link-group a { color: #00aeec; cursor: pointer; font-weight: normal; margin-left: 10px; font-size: 11px; letter-spacing: 0; } .section-title .link-group a:hover { text-decoration: underline; } .section-title .required-tip { color: #ff4d4f; font-weight: normal; font-size: 11px; letter-spacing: 0; } .form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } .form-row label { width: 62px; color: #666; font-size: 12px; flex-shrink: 0; } .form-row label.required::before { content: "*"; color: #ff4d4f; margin-right: 3px; } .form-row input { flex: 1; padding: 8px 12px; border: 1px solid #e0e0e0; border-radius: 7px; font-size: 13px; outline: none; font-family: inherit; color: #333; transition: border-color 0.15s, box-shadow 0.15s; } .form-row input:focus { border-color: #00aeec; box-shadow: 0 0 0 3px rgba(0,174,236,0.1); } .form-row input.invalid { border-color: #ff4d4f; box-shadow: 0 0 0 3px rgba(255,77,79,0.1); } .game-select { position: relative; } .game-select .game-input { width: 100%; padding: 8px 32px 8px 12px; border: 1px solid #e0e0e0; border-radius: 7px; font-size: 13px; outline: none; font-family: inherit; cursor: pointer; background: #fff; color: #333; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; } .game-select .game-input:focus { border-color: #00aeec; box-shadow: 0 0 0 3px rgba(0,174,236,0.1); } .game-select .arrow { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #999; font-size: 10px; pointer-events: none; } .game-dropdown { position: absolute; left: 0; right: 0; top: calc(100% + 4px); max-height: 240px; overflow-y: auto; background: #fff; border: 1px solid #e0e0e0; border-radius: 7px; box-shadow: 0 6px 20px rgba(0,0,0,0.12); z-index: 10; } .game-dropdown::-webkit-scrollbar { width: 6px; } .game-dropdown::-webkit-scrollbar-thumb { background: #ccc; border-radius: 3px; } .game-option { padding: 8px 12px; cursor: pointer; font-size: 12px; border-bottom: 1px solid #f5f5f5; } .game-option:last-child { border-bottom: none; } .game-option:hover, .game-option.focused { background: #f0f5ff; } .game-option.selected { background: #e6f7ff; color: #0096cc; font-weight: 600; } .game-option .gid { color: #999; font-size: 11px; margin-left: 6px; } button { padding: 11px 16px; border: none; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; transition: all 0.15s; } button.primary { background: #00aeec; color: #fff; width: 100%; } button.primary:hover:not(:disabled) { background: #0096cc; } button.primary:disabled { background: #ccc; cursor: not-allowed; } button.danger { background: #ff4d4f; color: #fff; width: 100%; } button.danger:hover { background: #e03d3f; } button.ghost { background: #fff; color: #00aeec; border: 1px solid #00aeec; width: 100%; } button.ghost:hover:not(:disabled) { background: #f0f9ff; } button.ghost:disabled { opacity: 0.5; cursor: not-allowed; } .stats { display: flex; gap: 8px; } .stat-item { flex: 1; background: #f5f7fa; border-radius: 9px; padding: 10px 6px; text-align: center; } .stat-item b { display: block; font-size: 20px; font-weight: 700; line-height: 1.2; } .stat-item b.blue { color: #00aeec; } .stat-item b.orange { color: #ff6b00; } .stat-item b.gray { color: #999; } .stat-item span { font-size: 11px; color: #888; margin-top: 2px; display: block; } .orders { max-height: 240px; overflow-y: auto; border: 1px solid #eee; border-radius: 9px; background: #fafbfc; } .orders::-webkit-scrollbar { width: 6px; } .orders::-webkit-scrollbar-thumb { background: #ccc; border-radius: 3px; } .order-row { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid #f0f0f0; font-size: 12px; transition: background 0.1s; } .order-row:last-child { border-bottom: none; } .order-row:hover { background: #f0f5ff; } .order-row.disabled { opacity: 0.45; } .order-row input[type=checkbox] { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: #00aeec; } .order-row input[type=checkbox]:disabled { cursor: not-allowed; } .order-info { flex: 1; min-width: 0; } .order-no { font-family: "SF Mono", Consolas, monospace; font-size: 11px; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .order-meta { display: flex; gap: 10px; margin-top: 3px; font-size: 11px; color: #999; } .order-amount { color: #ff6b00; font-weight: 600; } .badge { padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; white-space: nowrap; } .badge.todo { background: #e6f7ff; color: #0096cc; } .badge.done { background: #f0f0f0; color: #999; } .order-sum { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; margin-top: 8px; background: #e6f7ff; border-radius: 8px; font-size: 12px; color: #0077aa; } .order-sum b { font-size: 15px; color: #0088cc; font-family: "SF Mono", Consolas, monospace; } .record-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid #f0f0f0; font-size: 12px; } .record-row:last-child { border-bottom: none; } .record-info { flex: 1; min-width: 0; } .record-sn { font-family: "SF Mono", Consolas, monospace; font-size: 11px; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .record-meta { display: flex; gap: 10px; margin-top: 3px; font-size: 11px; color: #999; } .record-amount { color: #ff6b00; font-weight: 600; } .record-status { padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; } .record-status.s0 { background: #fff7e6; color: #d46b08; } .record-status.s1 { background: #e6f7ff; color: #0096cc; } .record-status.s2 { background: #f6ffed; color: #52c41a; } .record-status.s3 { background: #fff1f0; color: #cf1322; } .record-status.sX { background: #f0f0f0; color: #999; } .sum-box { background: linear-gradient(135deg, #fff7e6, #ffe7ba); border: 1px solid #ffd591; border-radius: 10px; padding: 16px 18px; text-align: center; } .sum-label { font-size: 12px; color: #ad6800; margin-bottom: 6px; } .sum-amount { font-size: 28px; font-weight: 700; color: #d46b08; font-family: "SF Mono", Consolas, monospace; line-height: 1.2; } .sum-meta { font-size: 11px; color: #ad6800; margin-top: 6px; opacity: 0.75; word-break: break-all; } .pagination { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 12px; font-size: 12px; color: #666; } .pagination button { padding: 6px 12px; font-size: 12px; background: #f5f7fa; color: #333; font-weight: normal; } .pagination button:hover:not(:disabled) { background: #e6f7ff; color: #00aeec; } .pagination button:disabled { opacity: 0.4; cursor: not-allowed; } .pagination .page-info { min-width: 80px; text-align: center; } .progress-bar { height: 6px; background: #f0f0f0; border-radius: 3px; overflow: hidden; margin-bottom: 6px; } .progress-inner { height: 100%; background: linear-gradient(90deg, #00aeec, #0088cc); width: 0%; transition: width 0.3s ease; } .progress-text { font-size: 11px; color: #666; text-align: center; } .log { max-height: 130px; overflow-y: auto; background: #1e1e1e; color: #ddd; border-radius: 9px; padding: 10px 12px; font-family: "SF Mono", Consolas, monospace; font-size: 11px; line-height: 1.6; } .log::-webkit-scrollbar { width: 6px; } .log::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; } .log-entry { margin-bottom: 2px; word-break: break-all; } .log-entry.ok { color: #4caf50; } .log-entry.err { color: #ff6b6b; } .log-entry.info { color: #64b5f6; } .log-entry.warn { color: #ffb74d; } .hidden { display: none !important; } `; shadow.appendChild(style); const root = document.createElement("div"); root.innerHTML = `
📄 开票助手
`; shadow.appendChild(root); // ============ 元素引用 ============ const $ = (id) => shadow.getElementById(id); const toggleBtn = $("toggleBtn"); const panel = $("panel"); const closeBtn = $("closeBtn"); const tabs = shadow.querySelectorAll(".tab"); const viewInvoice = $("viewInvoice"); const viewRecord = $("viewRecord"); const invoiceTitle = $("invoiceTitle"); const invoiceEmail = $("invoiceEmail"); const gameInput = $("gameInput"); const gameDropdown = $("gameDropdown"); const startDate = $("startDate"); const endDate = $("endDate"); const scanBtn = $("scanBtn"); const statsEl = $("stats"); const statTotal = $("statTotal"); const statTodo = $("statTodo"); const statDone = $("statDone"); const ordersSection = $("ordersSection"); const ordersEl = $("orders"); const orderSum = $("orderSum"); const orderSumSelected = $("orderSumSelected"); const orderSumBreakdown = $("orderSumBreakdown"); const selectTodo = $("selectTodo"); const selectNone = $("selectNone"); const progressSection = $("progressSection"); const progressInner = $("progressInner"); const progressText = $("progressText"); const logEl = $("log"); const invoiceBtn = $("invoiceBtn"); const stopBtn = $("stopBtn"); const refreshRecordBtn = $("refreshRecordBtn"); const sumRecordBtn = $("sumRecordBtn"); const recordStats = $("recordStats"); const recordTotal = $("recordTotal"); const recordAmount = $("recordAmount"); const sumResult = $("sumResult"); const sumAmount = $("sumAmount"); const sumMeta = $("sumMeta"); const recordList = $("recordList"); const recordPager = $("recordPager"); const prevPage = $("prevPage"); const nextPage = $("nextPage"); const pageInfo = $("pageInfo"); // 初始化输入框 invoiceTitle.value = state.invoiceTitle; invoiceEmail.value = state.invoiceEmail; endDate.value = fmtDate(new Date()); gameInput.value = `${state.game.name} (${state.game.id})`; // 扫描范围约束: // 1) 起始日期不早于 2019-01-01(月份下限 2019-01),结束不晚于今天 // 2) 保证起始 ≤ 结束,输入时自动钳制 const CURRENT_DATE = fmtDate(new Date()); function readRange() { let s = startDate.value || ""; let e = endDate.value || ""; if (s && s < MIN_START_DATE) { s = MIN_START_DATE; startDate.value = s; } if (s && s > CURRENT_DATE) { s = CURRENT_DATE; startDate.value = s; } if (e && e > CURRENT_DATE) { e = CURRENT_DATE; endDate.value = e; } if (s && e && s > e) { e = s; endDate.value = e; } return { sm: s ? payMonthOf(s) : "", em: e ? payMonthOf(e) : "" }; } function syncRangeBounds() { startDate.min = MIN_START_DATE; startDate.max = CURRENT_DATE; endDate.max = CURRENT_DATE; endDate.min = startDate.value || MIN_START_DATE; readRange(); } syncRangeBounds(); startDate.oninput = syncRangeBounds; endDate.oninput = syncRangeBounds; // ============ 校验 & 保存 ============ function validateTitle(silent = false) { const v = invoiceTitle.value.trim(); const tooLong = v.length > 100; const ok = v.length > 0 && !tooLong; if (!silent) { invoiceTitle.classList.toggle("invalid", !ok); if (tooLong) { alert("抬头过长,请检查"); } } return ok; } function validateEmail(silent = false) { const v = invoiceEmail.value.trim(); const ok = isValidEmail(v); if (!silent) { invoiceEmail.classList.toggle("invalid", !ok); } return ok; } function validateAll(silent = false) { const t = validateTitle(silent); const e = validateEmail(silent); return t && e; } invoiceTitle.oninput = () => { if (invoiceTitle.classList.contains("invalid")) { validateTitle(); } }; invoiceEmail.oninput = () => { if (invoiceEmail.classList.contains("invalid")) { validateEmail(); } }; function persistInfo() { const title = invoiceTitle.value.trim(); const email = invoiceEmail.value.trim(); state.invoiceTitle = title; state.invoiceEmail = email; saveCfg({ ...loadCfg(), title, email, game: state.game, }); } // ============ 日志 & 进度 ============ function log(msg, type = "info") { const t = new Date().toLocaleTimeString("zh-CN", { hour12: false, }); const div = document.createElement("div"); div.className = "log-entry " + type; div.textContent = `[${t}] ${msg}`; logEl.appendChild(div); while (logEl.children.length > 500) { logEl.firstElementChild.remove(); } logEl.scrollTop = logEl.scrollHeight; } function setProgress(cur, total, text) { progressSection.classList.remove("hidden"); progressInner.style.width = (total ? (cur / total) * 100 : 0) + "%"; progressText.textContent = text || `${cur} / ${total}`; } // ============ 面板开合 ============ toggleBtn.onclick = () => { panel.classList.toggle("hidden"); }; closeBtn.onclick = () => { panel.classList.add("hidden"); }; // ============ Tab 切换 ============ tabs.forEach((tab) => { tab.onclick = () => { const view = tab.dataset.view; state.view = view; tabs.forEach((t) => { t.classList.toggle("active", t === tab); }); viewInvoice.classList.toggle("hidden", view !== "invoice"); viewRecord.classList.toggle("hidden", view !== "record"); if ( view === "record" && (state.recordDirty || !state.recordList.length) ) { loadRecords(1); } }; }); // ============ 游戏选择器 ============ let gameDropdownVisible = false; async function ensureGameList() { if (state.gameList.length) return; try { const r = await apiGameList(); if (r.code !== 0) { log(`游戏列表获取失败: ${r.message || "未知错误"}`, "err"); return; } state.gameList = (r.data?.results || []).slice().sort((a, b) => { if (a.game_base_id === 97) { return -1; } if (b.game_base_id === 97) { return 1; } return a.name.localeCompare(b.name, "zh-CN"); }); } catch (e) { log(`游戏列表请求异常: ${formatError(e)}`, "err"); } } function renderGameDropdown(filter = "") { const kw = filter.trim().toLowerCase(); const list = state.gameList .filter( (g) => !kw || String(g.name || "") .toLowerCase() .includes(kw) || String(g.game_base_id).includes(kw), ) .slice(0, 100); gameDropdown.innerHTML = ""; if (!list.length) { const empty = document.createElement("div"); empty.className = "game-option"; empty.style.color = "#999"; empty.style.cursor = "default"; empty.textContent = "无匹配游戏"; gameDropdown.appendChild(empty); return; } for (const g of list) { const opt = document.createElement("div"); opt.className = "game-option" + (g.game_base_id === state.game.id ? " selected" : ""); const name = document.createElement("span"); name.textContent = g.name || "未知游戏"; const gid = document.createElement("span"); gid.className = "gid"; gid.textContent = `#${g.game_base_id}`; opt.append(name, gid); opt.onclick = () => { state.game = { id: g.game_base_id, name: g.name, }; gameInput.value = `${g.name} (${g.game_base_id})`; saveCfg({ ...loadCfg(), game: state.game, }); hideGameDropdown(); state.orders.clear(); state.selected.clear(); ordersEl.innerHTML = ""; ordersSection.classList.add("hidden"); statsEl.classList.add("hidden"); progressSection.classList.add("hidden"); updateInvoiceBtn(); }; gameDropdown.appendChild(opt); } } function showGameDropdown() { if (gameDropdownVisible) return; gameDropdownVisible = true; gameDropdown.classList.remove("hidden"); if (!state.gameList.length) { gameDropdown.innerHTML = '
加载中…
'; ensureGameList().then(() => { renderGameDropdown(""); }); } else { renderGameDropdown(""); } gameInput.readOnly = false; gameInput.value = ""; gameInput.placeholder = "输入游戏名或 ID 搜索…"; gameInput.focus(); } function hideGameDropdown() { if (!gameDropdownVisible) return; gameDropdownVisible = false; gameDropdown.classList.add("hidden"); gameInput.readOnly = true; gameInput.value = `${state.game.name} (${state.game.id})`; gameInput.placeholder = "点击选择游戏"; } gameInput.onclick = () => { if (gameDropdownVisible) { hideGameDropdown(); } else { showGameDropdown(); } }; gameInput.oninput = () => { if (gameDropdownVisible) { renderGameDropdown(gameInput.value); } }; gameInput.onkeydown = (e) => { if (e.key === "Escape") { hideGameDropdown(); } }; shadow.addEventListener("click", (e) => { if (gameDropdownVisible && !e.target.closest(".game-select")) { hideGameDropdown(); } }); // ============ 扫描 ============ async function scan() { if (state.scanning || state.invoicing) { return; } if (!validateAll()) { alert( "请先填写完整的发票信息:\n" + "· 抬头(不能为空)\n" + "· 邮箱(格式如 name@example.com)", ); if (!validateTitle(true)) { invoiceTitle.focus(); } else { invoiceEmail.focus(); } return; } const { sm, em } = readRange(); if (!sm || !em) { alert("请选择扫描月份范围"); return; } syncRangeBounds(); persistInfo(); state.orders.clear(); state.selected.clear(); state.scanning = true; state.stopFlag = false; const months = enumerateMonths(sm, em); scanBtn.disabled = true; scanBtn.textContent = "⏳ 正在扫描…"; invoiceBtn.disabled = true; ordersEl.innerHTML = ""; ordersSection.classList.add("hidden"); statsEl.classList.add("hidden"); progressSection.classList.remove("hidden"); log( `开始扫描【${state.game.name}】${sm} ~ ${em},共 ${months.length} 个月`, ); const controller = new AbortController(); state.requestController = controller; try { for (let i = 0; i < months.length; i++) { const m = months[i]; let skipped = 0; setProgress( i, months.length, `扫描 ${m} … (${i + 1}/${months.length})`, ); const first = await apiQuery(m, 1, controller.signal); if (first.code !== 0) { log(`[${m}] 查询失败: ${first.message || "未知错误"}`, "err"); continue; } const total = Number(first.data?.total_count ?? 0) || 0; if (total === 0) { log(`[${m}] 0 单`); continue; } const totalPages = Math.ceil(total / PAGE_SIZE); const pages = [first.data?.results ?? []]; let complete = true; for (let p = 2; p <= totalPages; p++) { await sleep(QUERY_DELAY, controller.signal); const r = await apiQuery(m, p, controller.signal); if (r.code !== 0) { complete = false; log( `[${m}] 第 ${p}/${totalPages} 页查询失败:${r.message || "未知错误"}`, "err", ); break; } pages.push(r.data?.results ?? []); } if (!complete) { log( `[${m}] 本月扫描不完整,未导入已获取结果;可重新扫描该月份`, "warn", ); continue; } for (const list of pages) { for (const o of list) { if (o && o.order_no && !state.orders.has(o.order_no)) { const om = payMonthOf(o.pay_date); if (om && (om < sm || om > em)) { skipped++; continue; } state.orders.set(o.order_no, o); } } } log( `[${m}] ${total} 单${skipped ? `,超出范围忽略 ${skipped}` : ""},累计 ${state.orders.size}`, ); await sleep(QUERY_DELAY, controller.signal); } setProgress( months.length, months.length, `扫描完成,共 ${state.orders.size} 单`, ); log(`扫描完成,共 ${state.orders.size} 单`, "ok"); renderOrders(); selectTodoOrders(); } catch (e) { if (e.name === "AbortError") { log("扫描请求已取消", "warn"); } else { log("扫描出错: " + formatError(e), "err"); } } finally { state.scanning = false; state.requestController = null; scanBtn.disabled = false; scanBtn.textContent = "🔍 扫描订单"; updateInvoiceBtn(); } } scanBtn.onclick = scan; // ============ 渲染订单 ============ function renderOrders() { ordersEl.innerHTML = ""; const all = [...state.orders.values()].sort((a, b) => (b.pay_date || "").localeCompare(a.pay_date || ""), ); for (const o of all) { const isTodo = o.invoice_status_name === STATUS_TODO; const row = document.createElement("div"); row.className = "order-row" + (isTodo ? "" : " disabled"); const cb = document.createElement("input"); cb.type = "checkbox"; cb.dataset.orderNo = o.order_no; cb.checked = state.selected.has(o.order_no); cb.disabled = !isTodo; cb.onchange = () => { if (cb.checked) { state.selected.add(o.order_no); } else { state.selected.delete(o.order_no); } updateInvoiceBtn(); }; const info = document.createElement("div"); info.className = "order-info"; const noDiv = document.createElement("div"); noDiv.className = "order-no"; noDiv.textContent = o.order_no; const metaDiv = document.createElement("div"); metaDiv.className = "order-meta"; const payDate = document.createElement("span"); payDate.textContent = o.pay_date || ""; const gameName = document.createElement("span"); gameName.textContent = o.game_name || ""; const amount = document.createElement("span"); amount.className = "order-amount"; amount.textContent = `¥${o.total_amount ?? "?"}`; metaDiv.append(payDate, gameName, amount); info.appendChild(noDiv); info.appendChild(metaDiv); const badge = document.createElement("span"); badge.className = "badge " + (isTodo ? "todo" : "done"); badge.textContent = o.invoice_status_name || "未知"; row.appendChild(cb); row.appendChild(info); row.appendChild(badge); ordersEl.appendChild(row); } const todoCount = all.filter( (o) => o.invoice_status_name === STATUS_TODO, ).length; statTotal.textContent = all.length; statTodo.textContent = todoCount; statDone.textContent = all.length - todoCount; statsEl.classList.remove("hidden"); ordersSection.classList.remove("hidden"); updateInvoiceBtn(); } // ============ 选择 ============ function syncCheckboxes() { ordersEl.querySelectorAll("input[type=checkbox]").forEach((cb) => { cb.checked = state.selected.has(cb.dataset.orderNo); }); } function selectTodoOrders() { state.selected.clear(); for (const o of state.orders.values()) { if (o.invoice_status_name === STATUS_TODO) { state.selected.add(o.order_no); } } syncCheckboxes(); updateInvoiceBtn(); } function selectNoneOrders() { state.selected.clear(); syncCheckboxes(); updateInvoiceBtn(); } selectTodo.onclick = selectTodoOrders; selectNone.onclick = selectNoneOrders; function updateInvoiceBtn() { const orderNos = [...state.selected]; invoiceBtn.textContent = `🎫 为选中的 ${orderNos.length} 个订单开票`; invoiceBtn.disabled = orderNos.length === 0 || state.invoicing || state.scanning; let sel = 0; let todo = 0; let other = 0; for (const o of state.orders.values()) { const amt = moneyToCents(o.total_amount); if (o.invoice_status_name === STATUS_TODO) { todo += amt; } else { other += amt; } if (state.selected.has(o.order_no)) { sel += amt; } } orderSumSelected.textContent = formatMoney(sel); orderSumBreakdown.textContent = `可开票 ${formatMoney(todo)} · 不可开票 ${formatMoney(other)}`; } // ============ 逐单开票 ============ async function runInvoice() { if (state.invoicing || state.scanning) { return; } if (!validateAll()) { alert("发票信息不完整或格式错误,请检查抬头和邮箱"); if (!validateTitle(true)) { invoiceTitle.focus(); } else { invoiceEmail.focus(); } return; } const orderNos = [...state.selected]; if (!orderNos.length) { return; } if (!confirm(`即将为 ${orderNos.length} 个订单申请开票,确定继续?`)) { return; } persistInfo(); state.invoicing = true; state.stopFlag = false; invoiceBtn.disabled = true; scanBtn.disabled = true; stopBtn.classList.remove("hidden"); progressSection.classList.remove("hidden"); const orders = orderNos.map((no) => state.orders.get(no)).filter(Boolean); let ok = 0; let fail = 0; let okAmount = 0; log(`开始逐单开票,共 ${orders.length} 单`, "info"); try { for (let i = 0; i < orders.length; i++) { if (state.stopFlag) { log("用户已停止", "warn"); break; } const o = orders[i]; setProgress(i, orders.length, `开票中 ${i + 1} / ${orders.length}`); const controller = new AbortController(); state.requestController = controller; try { const json = await apiCreate( o, state.invoiceTitle, state.invoiceEmail, controller.signal, ); if (json.code === 0) { ok++; okAmount += moneyToCents(o.total_amount); log(`✅ ${o.order_no} (${o.pay_date}) ¥${o.total_amount}`, "ok"); state.selected.delete(o.order_no); o.invoice_status_name = "已申请"; state.recordDirty = true; } else { fail++; log( `❌ ${o.order_no} [${json.code}] ${json.message || "未知错误"}`, "err", ); } } catch (e) { if (state.stopFlag && e.name === "AbortError") { log("当前请求已取消", "warn"); break; } fail++; log(`❌ ${o.order_no} 请求异常: ${formatError(e)}`, "err"); } finally { if (state.requestController === controller) { state.requestController = null; } } if (!state.stopFlag && i < orders.length - 1) { await sleep(CREATE_DELAY); } } } catch (e) { if (e.name === "AbortError" && state.stopFlag) { log("用户已停止", "warn"); } else { log(`开票流程异常:${formatError(e)}`, "err"); } } finally { setProgress( orders.length, orders.length, `完成:成功 ${ok},失败 ${fail}`, ); log( `开票完成:成功 ${ok} 单(${formatMoney(okAmount)}),失败 ${fail} 单${state.stopFlag ? "(已停止)" : ""}`, fail ? "warn" : "ok", ); state.invoicing = false; state.stopFlag = false; state.requestController = null; stopBtn.classList.add("hidden"); scanBtn.disabled = false; renderOrders(); updateInvoiceBtn(); } } invoiceBtn.onclick = runInvoice; stopBtn.onclick = () => { if (!state.invoicing) { return; } state.stopFlag = true; state.requestController?.abort(); }; // ============ 开票记录 ============ function setRecordMessage(message, type = "info") { recordList.innerHTML = ""; const div = document.createElement("div"); div.style.cssText = "padding:20px;text-align:center"; div.style.color = type === "error" ? "#ff4d4f" : "#999"; div.textContent = message; recordList.appendChild(div); } async function loadRecords(pageNo) { if (pageNo < 1 || state.invoicing) { return; } refreshRecordBtn.disabled = true; refreshRecordBtn.textContent = "⏳ 加载中…"; recordStats.classList.add("hidden"); recordPager.classList.add("hidden"); sumResult.classList.add("hidden"); setRecordMessage("加载中…"); try { const r = await apiRecordQuery(pageNo, RECORD_PAGE_SIZE); if (r.code !== 0) { setRecordMessage(`加载失败:${r.message || "未知错误"}`, "error"); return; } const total = Number(r.data?.total_count ?? 0) || 0; const results = r.data?.results ?? []; state.recordList = Array.isArray(results) ? results : []; state.recordPage = pageNo; state.recordTotal = total; state.recordDirty = false; renderRecords(); renderPager(); } catch (e) { setRecordMessage(`请求异常:${formatError(e)}`, "error"); } finally { refreshRecordBtn.disabled = false; refreshRecordBtn.textContent = "🔄 刷新记录"; } } function renderRecords() { recordList.innerHTML = ""; if (!state.recordList.length) { setRecordMessage("暂无开票记录"); recordStats.classList.add("hidden"); return; } let sum = 0; for (const r of state.recordList) { sum += moneyToCents(r.total_full_amount); const row = document.createElement("div"); row.className = "record-row"; const info = document.createElement("div"); info.className = "record-info"; const snDiv = document.createElement("div"); snDiv.className = "record-sn"; snDiv.textContent = r.invoice_sn || "-"; const metaDiv = document.createElement("div"); metaDiv.className = "record-meta"; const gameName = document.createElement("span"); gameName.textContent = r.game_name || ""; const amount = document.createElement("span"); amount.className = "record-amount"; amount.textContent = `¥${r.total_full_amount ?? "?"}`; metaDiv.append(gameName, amount); info.appendChild(snDiv); info.appendChild(metaDiv); const status = document.createElement("span"); const statusCode = String(r.invoice_status ?? "X"); const statusKey = /^[0-3]$/.test(statusCode) ? statusCode : "X"; status.className = "record-status s" + statusKey; status.textContent = r.invoice_status_name || "未知"; row.appendChild(info); row.appendChild(status); recordList.appendChild(row); } recordTotal.textContent = state.recordTotal; recordAmount.textContent = formatMoney(sum); recordStats.classList.remove("hidden"); } function renderPager() { const totalPages = Math.max( 1, Math.ceil(state.recordTotal / RECORD_PAGE_SIZE), ); pageInfo.textContent = `${state.recordPage} / ${totalPages}`; prevPage.disabled = state.recordPage <= 1; nextPage.disabled = state.recordPage >= totalPages; recordPager.classList.toggle("hidden", totalPages <= 1); } refreshRecordBtn.onclick = () => loadRecords(1); prevPage.onclick = () => loadRecords(state.recordPage - 1); nextPage.onclick = () => loadRecords(state.recordPage + 1); // ============ 统计全部开票金额 ============ async function sumAllRecords() { if (sumRecordBtn.disabled || state.invoicing) { return; } sumRecordBtn.disabled = true; sumRecordBtn.textContent = "⏳ 统计中…"; sumResult.classList.add("hidden"); let page = 1; let totalCount = 0; let totalAmount = 0; const statusStats = {}; try { while (true) { const r = await apiRecordQuery(page, SUM_PAGE_SIZE); if (r.code !== 0) { throw new Error(r.message || "未知错误"); } const results = Array.isArray(r.data?.results) ? r.data.results : []; totalCount = Number(r.data?.total_count ?? totalCount) || 0; for (const rec of results) { totalAmount += moneyToCents(rec.total_full_amount); const st = rec.invoice_status_name || "未知"; statusStats[st] = (statusStats[st] || 0) + 1; } const doneByCount = page * SUM_PAGE_SIZE >= totalCount; if ( results.length === 0 || results.length < SUM_PAGE_SIZE || doneByCount ) { break; } page++; sumMeta.textContent = `已统计第 ${page - 1} 页…`; await sleep(200); } sumAmount.textContent = formatMoney(totalAmount); const statusText = Object.entries(statusStats) .map(([k, v]) => `${k} ${v}`) .join(" · "); sumMeta.textContent = `共 ${totalCount} 条` + (statusText ? ` · ${statusText}` : ""); sumResult.classList.remove("hidden"); recordTotal.textContent = totalCount; recordStats.classList.remove("hidden"); } catch (e) { sumAmount.textContent = "¥?"; sumMeta.textContent = "统计失败:" + formatError(e); sumResult.classList.remove("hidden"); } finally { sumRecordBtn.disabled = false; sumRecordBtn.textContent = "💰 统计总金额"; } } sumRecordBtn.onclick = sumAllRecords; // ============ 首次提示 ============ if (!state.invoiceTitle || !state.invoiceEmail) { log("首次使用:请先填写「抬头」和「邮箱」", "warn"); } else { log("助手已就绪,请确认发票信息后点击「扫描订单」"); } })();