// ==UserScript== // @name 中国银行 · 申卡信息提取器 // @namespace https://open.boc.cn/bankcard/ // @version 2.1.0 // @description 提取 open.boc.cn 办卡进度页(#/applyProgressDetail)Vue 实例中的 recordInfo:悬浮球 + 深色弹窗展示制卡进度/卡号,支持复制、状态监控告警、多卡识别、导出。数据仅读取自当前页面本地缓存,不发起任何网络请求。 // @author WorkBuddy // @match https://open.boc.cn/bankcard/cardReserve/index.html* // @run-at document-idle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_notification // @grant GM_addStyle // @grant GM_openInTab // ==/UserScript== (function () { 'use strict'; /* ============================ 配置 ============================ */ // 状态码 -> 文案 / 颜色(已按实际取值修正) const STATUS_MAP = { '00': { text: '待制卡', color: '#5ac8fa', tag: 'normal' }, '01': { text: '制卡中', color: '#4cd964', tag: 'normal' }, '02': { text: '流程异常', color: '#ff3b30', tag: 'error' }, '03': { text: '寄送中', color: '#ff9500', tag: 'normal' }, '04': { text: '待领卡', color: '#ffcc00', tag: 'normal' }, '05': { text: '流程异常', color: '#ff3b30', tag: 'error' }, '06': { text: '已领卡', color: '#af52de', tag: 'normal' }, '07': { text: '流程异常', color: '#ff3b30', tag: 'error' }, }; const HIST_KEY = 'boc_card_status_history'; const MAX_HISTORY = 300; const state = { pollMs: GM_getValue('pollMs', 3000), autoPoll: GM_getValue('autoPoll', true), alertOnChange: GM_getValue('alertOnChange', true), watchStatus: GM_getValue('watchStatus', []), records: [], lastSig: '', timer: null, modalOpen: false, showFull: {}, // cardKey -> bool 是否显示完整卡号 rawOpen: {}, // cardKey -> bool 是否展开原始数据 }; /* ============================ 数据抓取 ============================ */ function wrapV3(el) { const v3 = el && el.__vueParentComponent; if (!v3) return null; return { parent: v3.parent || null, setupState: v3.setupState, proxy: v3.proxy, ctx: v3.ctx }; } function readFromChain(start) { let cur = start, depth = 0; while (cur && depth < 15) { const cands = []; try { cands.push(cur.recordInfo); } catch (e) {} try { if (cur.$data) cands.push(cur.$data.recordInfo); } catch (e) {} try { if (cur.setupState) cands.push(cur.setupState.recordInfo); } catch (e) {} try { if (cur.proxy) cands.push(cur.proxy.recordInfo); } catch (e) {} try { if (cur.ctx) cands.push(cur.ctx.recordInfo); } catch (e) {} for (const c of cands) { if (c && typeof c === 'object') { const arr = Array.isArray(c) ? c : [c]; if (arr.length && arr.some((x) => x && (x.cardNo || x.mkcrdProgreSts))) { return arr.filter((x) => x && typeof x === 'object'); } } } cur = cur.$parent || cur.parent; depth++; } return null; } let cachedEl = null; function scanRecords() { let list = null; const pref = document.querySelector('.progress-result-wrap'); if (pref) { list = readFromChain(pref.__vue__ || wrapV3(pref)); if (list) cachedEl = pref; } if (!list && cachedEl && document.contains(cachedEl)) { list = readFromChain(cachedEl.__vue__ || wrapV3(cachedEl)); } if (!list) { const nodes = document.querySelectorAll('*'); for (let i = 0; i < nodes.length; i++) { const el = nodes[i]; const inst = el.__vue__ || wrapV3(el); if (!inst) continue; list = readFromChain(inst); if (list) { cachedEl = el; break; } } } return list || []; } function normalize(raw) { return { cardNo: raw.cardNo || '', custName: raw.custName || '', idCardNo: raw.idCardNo || '', branchName: raw.branchName || '', alsalPdName: raw.alsalPdName || raw.prodName || '', mkcrdProgreSts: String(raw.mkcrdProgreSts || ''), createDate: raw.createDate || '', _raw: raw, }; } const keyOf = (r) => `${r.cardNo}|${r.alsalPdName}|${r.createDate}`; const statusInfo = (code) => STATUS_MAP[code] || { text: `未知状态(${code || '空'})`, color: '#8e8e93', tag: 'unknown' }; function collect() { const map = new Map(); scanRecords().forEach((raw) => { const r = normalize(raw); map.set(keyOf(r), r); }); const sig = [...map.values()].map((r) => `${keyOf(r)}=>${r.mkcrdProgreSts}`).sort().join('\n'); if (state.lastSig && sig !== state.lastSig) { diffNotify(state.records, [...map.values()]); } state.lastSig = sig; state.records = [...map.values()]; renderFab(); if (state.modalOpen) renderModal(); return state.records; } /* ============================ 状态变化告警 ============================ */ function diffNotify(oldList, newList) { const oldMap = new Map(oldList.map((r) => [keyOf(r), r])); const changes = []; newList.forEach((r) => { const old = oldMap.get(keyOf(r)); if (!old) { changes.push({ t: Date.now(), cardNo: r.cardNo, prod: r.alsalPdName, from: '(新增)', to: r.mkcrdProgreSts }); } else if (old.mkcrdProgreSts !== r.mkcrdProgreSts) { changes.push({ t: Date.now(), cardNo: r.cardNo, prod: r.alsalPdName, from: old.mkcrdProgreSts, to: r.mkcrdProgreSts }); } }); if (!changes.length) return; const hist = GM_getValue(HIST_KEY, []); hist.unshift(...changes); GM_setValue(HIST_KEY, hist.slice(0, MAX_HISTORY)); if (!state.alertOnChange) return; const matched = state.watchStatus.length === 0 || changes.some((c) => state.watchStatus.includes(String(c.to))); if (!matched) return; const body = changes .map((c) => `${c.cardNo}\n${statusInfo(c.from).text} → ${statusInfo(c.to).text}`) .join('\n'); try { GM_notification({ title: '中国银行 · 办卡进度变化', text: body, timeout: 15000, onclick: () => { window.focus(); openModal(); }, }); } catch (e) {} beep(); } function beep() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); [880, 1180].forEach((f, i) => { const o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.connect(g); g.connect(ctx.destination); const t0 = ctx.currentTime + i * 0.18; g.gain.setValueAtTime(0.001, t0); g.gain.exponentialRampToValueAtTime(0.25, t0 + 0.01); g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.16); o.start(t0); o.stop(t0 + 0.18); }); } catch (e) {} } /* ============================ 工具 ============================ */ function fmtCard(no) { const s = String(no || ''); if (s.includes('*')) return s; // 页面返回的已是脱敏值 if (s.length >= 12) return `${s.slice(0, 6)} **** **** ${s.slice(-4)}`; return s; } const hasFullCard = (no) => { const s = String(no || ''); return s && !s.includes('*'); }; function copyText(txt, tip) { const done = () => toast(tip || '已复制'); if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(txt).then(done).catch(() => { fallbackCopy(txt); done(); }); } else { fallbackCopy(txt); done(); } } function fallbackCopy(txt) { const ta = document.createElement('textarea'); ta.value = txt; ta.style.cssText = 'position:fixed;opacity:0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} ta.remove(); } function download(name, content, type) { const blob = new Blob([content], { type: type || 'application/json;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); } function toCsv(list) { const head = ['cardNo', 'custName', 'idCardNo', 'branchName', 'alsalPdName', 'mkcrdProgreSts', 'statusText', 'createDate']; const rows = list.map((r) => [r.cardNo, r.custName, r.idCardNo, r.branchName, r.alsalPdName, r.mkcrdProgreSts, statusInfo(r.mkcrdProgreSts).text, r.createDate]); const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; return '\ufeff' + [head.join(','), ...rows.map((r) => r.map(esc).join(','))].join('\r\n'); } function stamp() { const d = new Date(), p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; } function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } let toastTimer = null; function toast(msg) { let el = document.getElementById('boc-toast'); if (!el) { el = document.createElement('div'); el.id = 'boc-toast'; el.style.cssText = 'position:fixed;left:50%;bottom:120px;transform:translateX(-50%);z-index:2147483601;' + 'background:rgba(20,20,22,.92);color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;pointer-events:none'; document.body.appendChild(el); } el.textContent = msg; el.style.display = 'block'; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.style.display = 'none'; }, 1800); } /* ============================ 样式 ============================ */ GM_addStyle(` #boc-fab{position:fixed;right:18px;bottom:22px;z-index:2147483600;width:56px;height:56px;border-radius:50%; background:#c62828;border:none;cursor:pointer;box-shadow:0 6px 18px rgba(0,0,0,.35); display:flex;align-items:center;justify-content:center;font-size:24px;transition:transform .15s} #boc-fab:hover{transform:scale(1.06)} #boc-fab .boc-badge-n{position:absolute;top:-4px;right:-4px;min-width:20px;height:20px;border-radius:10px; background:#ffcc00;color:#7a3c00;font:700 12px/20px -apple-system,"Segoe UI","Microsoft YaHei",sans-serif; text-align:center;padding:0 5px;box-shadow:0 2px 6px rgba(0,0,0,.3)} #boc-modal-mask{position:fixed;inset:0;z-index:2147483600;background:rgba(0,0,0,.45); display:flex;align-items:flex-start;justify-content:center;padding:8vh 12px 12px} #boc-modal{width:100%;max-width:430px;max-height:84vh;overflow:auto;background:#17181a;border-radius:16px; box-shadow:0 12px 40px rgba(0,0,0,.5);font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;color:#e8e8ea} .boc-m-head{position:sticky;top:0;display:flex;align-items:center;background:#c62828;color:#fff; padding:14px 16px;border-radius:16px 16px 0 0;z-index:2} .boc-m-head b{flex:1;font-size:16px;letter-spacing:.5px} .boc-m-close{background:none;border:none;color:#fff;font-size:22px;cursor:pointer;line-height:1;padding:0 2px} .boc-m-body{padding:16px} .boc-m-card{padding:4px 0 10px} .boc-m-row{display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin:10px 0} .boc-m-label{color:#9a9a9f;font-size:14px;white-space:nowrap} .boc-m-status{color:#4cd964;font-weight:700;font-size:16px;white-space:nowrap} .boc-m-cardno{font-weight:700;font-size:20px;letter-spacing:1px;color:#fff;text-align:right;word-break:break-all} .boc-m-link{display:block;text-align:right;color:#5ac8fa;font-size:13px;background:none;border:none;cursor:pointer;padding:2px 0 8px} .boc-m-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:8px} .boc-m-btn{background:#2a2b2e;color:#fff;border:1px solid #3a3b3f;border-radius:8px;padding:11px 0; font-size:14px;cursor:pointer;text-align:center} .boc-m-btn:hover{background:#333438} .boc-m-btn:active{background:#3d3e43} .boc-m-raw{background:#0d0d0f;border:1px solid #2c2d31;border-radius:8px;color:#9fe8b8; font:12px/1.5 Consolas,monospace;padding:10px;margin-top:10px;white-space:pre-wrap;word-break:break-all;max-height:220px;overflow:auto} .boc-m-extra{display:flex;justify-content:space-between;font-size:12px;color:#8e8e93;margin:8px 0 0} .boc-m-foot{color:#77777c;font-size:12px;text-align:center;padding:6px 16px 16px} .boc-m-empty{color:#9a9a9f;text-align:center;padding:26px 0} .boc-m-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px} `); /* ============================ 悬浮球 ============================ */ let fab = null; function renderFab() { if (!onRoute()) { if (fab) fab.style.display = 'none'; return; } if (!fab) { fab = document.createElement('button'); fab.id = 'boc-fab'; fab.title = '申卡信息'; fab.innerHTML = '💳'; fab.addEventListener('click', openModal); document.body.appendChild(fab); } fab.style.display = 'flex'; const n = state.records.filter((r) => matchWatch(r.mkcrdProgreSts)).length; fab.innerHTML = '💳' + (n ? `${n}` : ''); } function matchWatch(code) { return state.watchStatus.length === 0 || state.watchStatus.includes(String(code)); } /* ============================ 弹窗 ============================ */ let mask = null; function openModal() { state.modalOpen = true; renderModal(); } function closeModal() { state.modalOpen = false; if (mask) mask.remove(); mask = null; } function renderModal() { if (!mask) { mask = document.createElement('div'); mask.id = 'boc-modal-mask'; mask.addEventListener('click', (e) => { if (e.target === mask) closeModal(); }); document.body.appendChild(mask); } const cards = state.records; let bodyHtml; if (!cards.length) { bodyHtml = `
${escapeHtml(JSON.stringify(r._raw, null, 2))}` : ''}