// ==UserScript== // @name 淘宝/天猫 卖家中心 - SKU 订单查找 + 导出 XLSX(含留言/客服备注/收货地址/订单状态) // @namespace https://myseller.taobao.com/ // @version 2.0 // @description 卖家中心右上角悬浮面板:按 SKU 属性值反查主订单号,自动补齐客户留言、客服备注、收货地址、订单状态,一键导出 XLSX(一笔多件自动拆行)。 // @author WorkBuddy // @match https://myseller.taobao.com/* // @match https://trade.taobao.com/* // @match https://qn.taobao.com/* // @connect trade.taobao.com // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @grant GM_addStyle // @run-at document-idle // ==/UserScript== (function () { 'use strict'; /* ========================= 接口配置 ========================= */ const API_LIST = 'https://trade.taobao.com/trade/itemlist/asyncSold.htm?event_submit_do_query=1&_input_charset=utf8'; const API_MSG = 'https://trade.taobao.com/trade/json/getMessage.htm'; // 不同账号/店铺类型(天猫 vs 淘宝)走的详情接口不同,脚本会自动逐个尝试并记住可用的那个 const DETAIL_COMBOS = [ { url: (id) => 'https://trade.taobao.com/detail/orderDetailQianNiu.htm?bizOrderId=' + id + '&sifg=1&isQnNew=true&isHideNick=true', qn: true, tag: '天猫版详情' }, { url: (id) => 'https://trade.taobao.com/trade/detail/trade_order_detail_qian_niu.htm?bizOrderId=' + id + '&sifg=1&isQnNew=true&isHideNick=true', qn: true, tag: '淘宝版详情' }, { url: (id) => 'https://trade.taobao.com/detail/orderDetailQianNiu.htm?bizOrderId=' + id + '&sifg=1&isQnNew=true&isHideNick=true', qn: false, tag: '天猫版详情(卖家中心来源)' }, { url: (id) => 'https://trade.taobao.com/trade/detail/trade_order_detail_qian_niu.htm?bizOrderId=' + id + '&sifg=1&isQnNew=true&isHideNick=true', qn: false, tag: '淘宝版详情(卖家中心来源)' } ]; let detailCombo = -1; // -1 尚未确定,>=0 已命中的下标 const DEFAULT_REFERER = 'https://myseller.taobao.com/home.htm/trade-platform/tp/sold'; const CACHE_TTL = 60 * 1000; const listCache = new Map(); const msgCache = new Map(); const detailCache = new Map(); const TAB_OPTIONS = [ { code: 'waitSend', label: '待发货', status: 'PAID' }, { code: 'haveSendGoods', label: '已发货', status: 'SEND' }, { code: 'success', label: '已成功', status: 'SUCCESS' }, { code: 'refunding', label: '退款中', status: 'REFUNDING' }, { code: 'waitBuyerPay', label: '待付款', status: 'NOT_PAID' }, { code: 'closed', label: '已关闭', status: 'DROP' }, { code: 'latest3Months', label: '近3个月全部', status: 'ALL' } ]; /* ========================= 通用工具 ========================= */ const sleep = (ms) => new Promise(r => setTimeout(r, ms)); function encodeForm(obj) { return Object.keys(obj).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(obj[k])).join('&'); } // trade.taobao.com 系列接口返回 charset=GBK,按 UTF-8 读会乱码,这里择优 function decodeBody(buf) { const bytes = new Uint8Array(buf); const bad = s => (s.match(/\uFFFD/g) || []).length; const gbk = safeDecode(bytes, 'gbk'); const utf8 = safeDecode(bytes, 'utf-8'); return bad(gbk) <= bad(utf8) ? gbk : utf8; } function safeDecode(bytes, enc) { try { return new TextDecoder(enc).decode(bytes); } catch (e) { try { return new TextDecoder('utf-8').decode(bytes); } catch (e2) { return ''; } } } function gmRequest(opt) { return new Promise((resolve, reject) => { GM_xmlhttpRequest(Object.assign({ responseType: 'arraybuffer', timeout: 30000, onerror: (e) => reject(new Error('网络错误:' + (e.statusText || '未知'))), ontimeout: () => reject(new Error('请求超时')) }, opt, { onload: (res) => { if (res.status !== 200) { reject(new Error('HTTP ' + res.status)); return; } resolve(decodeBody(res.response)); } })); }); } function baseHeaders() { return { 'Accept': 'application/json, text/plain, */*', 'X-Requested-With': 'XMLHttpRequest', 'Origin': 'https://myseller.taobao.com', 'Referer': location.host.indexOf('taobao.com') > -1 ? location.href : DEFAULT_REFERER }; } function parseJsonSafe(text) { const t = String(text).trim() .replace(/^]*>/i, '').replace(/<\/pre>$/i, '') .replace(/^\ufeff/, ''); try { return JSON.parse(t); } catch (e) { return null; } } /* ===== EXPORT_LIB_BEGIN ===== */ // —— ZIP(STORE 模式)打包,用于生成 xlsx —— const CRC_TABLE = (function () { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } return t; })(); function crc32(u8) { let c = 0xffffffff; for (let i = 0; i < u8.length; i++) c = CRC_TABLE[(c ^ u8[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function zipStore(files) { const enc = new TextEncoder(); const parts = [], centrals = []; let offset = 0; const now = new Date(); const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | Math.floor(now.getSeconds() / 2)) & 0xffff; const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff; files.forEach(f => { const nameU8 = enc.encode(f.name); const dataU8 = (typeof f.data === 'string') ? enc.encode(f.data) : f.data; const crc = crc32(dataU8); const lh = new Uint8Array(30 + nameU8.length); const lv = new DataView(lh.buffer); lv.setUint32(0, 0x04034b50, true); lv.setUint16(4, 20, true); lv.setUint16(6, 0x0800, true); lv.setUint16(8, 0, true); lv.setUint16(10, dosTime, true); lv.setUint16(12, dosDate, true); lv.setUint32(14, crc, true); lv.setUint32(18, dataU8.length, true); lv.setUint32(22, dataU8.length, true); lv.setUint16(26, nameU8.length, true); lv.setUint16(28, 0, true); lh.set(nameU8, 30); const ch = new Uint8Array(46 + nameU8.length); const cv = new DataView(ch.buffer); cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true); cv.setUint16(8, 0x0800, true); cv.setUint16(10, 0, true); cv.setUint16(12, dosTime, true); cv.setUint16(14, dosDate, true); cv.setUint32(16, crc, true); cv.setUint32(20, dataU8.length, true); cv.setUint32(24, dataU8.length, true); cv.setUint16(28, nameU8.length, true); cv.setUint16(30, 0, true); cv.setUint16(32, 0, true); cv.setUint16(34, 0, true); cv.setUint16(36, 0, true); cv.setUint32(38, 0, true); cv.setUint32(42, offset, true); ch.set(nameU8, 46); parts.push(lh, dataU8); centrals.push(ch); offset += lh.length + dataU8.length; }); const cdStart = offset; let cdSize = 0; centrals.forEach(c => { cdSize += c.length; }); const eocd = new Uint8Array(22); const ev = new DataView(eocd.buffer); ev.setUint32(0, 0x06054b50, true); ev.setUint16(4, 0, true); ev.setUint16(6, 0, true); ev.setUint16(8, files.length, true); ev.setUint16(10, files.length, true); ev.setUint32(12, cdSize, true); ev.setUint32(16, cdStart, true); ev.setUint16(20, 0, true); let total = cdSize + 22; parts.forEach(p => { total += p.length; }); const out = new Uint8Array(total); let pos = 0; parts.forEach(p => { out.set(p, pos); pos += p.length; }); centrals.forEach(c => { out.set(c, pos); pos += c.length; }); out.set(eocd, pos); return out; } function xmlEsc(s) { return String(s == null ? '' : s) .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function colLetter(i) { let s = ''; i = i + 1; while (i > 0) { const m = (i - 1) % 26; s = String.fromCharCode(65 + m) + s; i = Math.floor((i - 1) / 26); } return s; } // rows: 二维数组。第一行为表头。数字型单元格自动识别 function buildXlsxBytes(rows, widths) { let xml = '' + ''; if (widths && widths.length) { xml += ''; widths.forEach((w, i) => { xml += ''; }); xml += ''; } xml += ''; rows.forEach((row, ri) => { xml += ''; row.forEach((val, ci) => { const ref = colLetter(ci) + (ri + 1); if (typeof val === 'number' && isFinite(val)) { xml += '' + val + ''; } else { const t = String(val == null ? '' : val); if (t === '') { xml += ''; return; } xml += '' + xmlEsc(t) + ''; } }); xml += ''; }); xml += ''; return zipStore([ { name: '[Content_Types].xml', data: '' + '' + '' + '' + '' + '' + '' }, { name: '_rels/.rels', data: '' + '' + '' + '' }, { name: 'xl/workbook.xml', data: '' + '' + '' }, { name: 'xl/_rels/workbook.xml.rels', data: '' + '' + '' + '' }, { name: 'xl/worksheets/sheet1.xml', data: xml } ]); } /* ===== EXPORT_LIB_END ===== */ /* ========================= 1. 列表接口 ========================= */ function buildForm(pageNum, tabCode, orderStatus, pageSize) { return { isQnNew: 'true', isHideNick: 'true', prePageNo: String(pageNum), sifg: '0', action: 'itemlist/SoldQueryAction', close: '0', pageNum: String(pageNum), tabCode: tabCode, useCheckcode: 'false', errorCheckcode: 'false', payDateBegin: '0', rateStatus: 'ALL', buyerNick: '', orderStatus: orderStatus, pageSize: String(pageSize), dateEnd: '0', endTimeBegin: '0', endTimeEnd: '0', rxOldFlag: '0', rxSendFlag: '0', dateBegin: '0', tradeTag: '0', rxHasSendFlag: '0', auctionType: '0', sellerNick: '', notifySendGoodsType: 'ALL', sellerMemoFlag: '0', useOrderInfo: 'false', logisticsService: 'ALL', o2oDeliveryType: 'ALL', rxAuditFlag: '0', queryOrder: 'desc', holdStatus: '0', rxElectronicAuditFlag: '0', queryMore: 'false', payDateEnd: '0', rxWaitSendflag: '0', sellerMemo: '0', rxElectronicAllFlag: '0', rxSuccessflag: '0', unionSearchTotalNum: '0', refund: 'ALL', unionSearchPageNum: '0', yushouStatus: 'ALL', deliveryTimeType: 'ALL', payMethodType: 'ALL', orderType: 'ALL', appName: 'ALL', isRiskOrder: '0', buyerEncodeId: '', unionSearch: '' }; } async function requestPage(pageNum, tabCode, orderStatus, pageSize) { const text = await gmRequest({ method: 'POST', url: API_LIST, headers: Object.assign(baseHeaders(), { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }), data: encodeForm(buildForm(pageNum, tabCode, orderStatus, pageSize)) }); const json = parseJsonSafe(text); if (!json) throw new Error('列表接口返回的不是 JSON(登录过期或被风控):' + text.slice(0, 60)); return json; } async function loadOrders(tabCode, orderStatus, pageSize, maxPages, interval, force, onProgress) { const key = tabCode + '|' + pageSize; if (!force) { const c = listCache.get(key); if (c && Date.now() - c.ts < CACHE_TTL) { onProgress('使用缓存数据(' + c.orders.length + ' 单),如需最新请勾选「强制刷新」'); return c.orders; } } onProgress('正在拉取第 1 页…'); const first = await requestPage(1, tabCode, orderStatus, pageSize); if (!first || !Array.isArray(first.mainOrders)) { throw new Error('列表接口未返回订单数据,请确认已登录卖家中心并刷新页面重试'); } const all = first.mainOrders.slice(); const total = (first.page && first.page.totalNumber) || all.length; const totalPage = (first.page && first.page.totalPage) || 1; const pages = Math.min(totalPage, maxPages); onProgress('共 ' + total + ' 单 / ' + totalPage + ' 页,计划抓取 ' + pages + ' 页…'); for (let p = 2; p <= pages; p++) { await sleep(interval); onProgress('正在拉取第 ' + p + ' / ' + pages + ' 页…'); let d; try { d = await requestPage(p, tabCode, orderStatus, pageSize); } catch (e) { onProgress('第 ' + p + ' 页失败:' + e.message + ',用已抓数据继续'); break; } if (!d || !Array.isArray(d.mainOrders) || !d.mainOrders.length) break; all.push.apply(all, d.mainOrders); } listCache.set(key, { ts: Date.now(), orders: all }); onProgress('抓取完成,共 ' + all.length + ' 单'); return all; } /* ========================= 2. 客户留言 ========================= */ async function fetchMessage(orderId) { if (msgCache.has(orderId)) return msgCache.get(orderId); const p = (async () => { try { const text = await gmRequest({ method: 'POST', url: API_MSG + '?biz_order_id=' + encodeURIComponent(orderId) + '&archive=false', headers: baseHeaders() }); const json = parseJsonSafe(text); if (!json) return { ok: false, text: '接口返回异常' }; let tip = String(json.tip == null ? '' : json.tip).trim(); tip = tip.replace(/^留言[::]\s*/, ''); return { ok: true, text: tip, empty: !tip || tip === '无' }; } catch (e) { return { ok: false, text: '获取失败:' + e.message }; } })(); msgCache.set(orderId, p); return p; } /* ========================= 3. 订单详情:收货地址 + 客服备注 ========================= */ function splitAddress(s) { const r = { raw: s || '', name: '', phone: '', detail: '', zip: '' }; if (!r.raw) return r; const parts = r.raw.split(',').map(x => x.trim()).filter(Boolean); r.name = parts[0] || ''; r.phone = parts[1] || ''; const rest = parts.slice(2); if (rest.length && /^\d{6}$/.test(rest[rest.length - 1])) r.zip = rest.pop(); r.detail = rest.join(' ').trim(); return r; } // 客服备注:detailExtra.sellerMemoInfo.memo —— 和收货地址同一个接口,不额外发请求 function extractMemo(json) { try { const mi = (json.detailExtra && json.detailExtra.sellerMemoInfo) || {}; if (typeof mi.memo !== 'string' || !mi.memo.trim()) return ''; return mi.memo.replace(//gi, ' ').replace(/<[^>]+>/g, '').trim(); } catch (e) { return ''; } } function findAddressStr(json) { const tabs = json.tabs || []; for (const t of tabs) { if (t && t.content && t.content.address) return String(t.content.address); } const stack = [json]; while (stack.length) { const cur = stack.pop(); if (!cur || typeof cur !== 'object') continue; if (typeof cur.address === 'string' && cur.address) return cur.address; for (const k in cur) { if (Object.prototype.hasOwnProperty.call(cur, k) && cur[k] && typeof cur[k] === 'object') stack.push(cur[k]); } } return ''; } function qnHeaders(orderId) { return { 'Accept': 'application/json, text/plain, */*', 'X-Requested-With': 'XMLHttpRequest', 'Origin': 'https://qn.taobao.com', 'Referer': 'https://qn.taobao.com/home.htm/trade-platform/tp/detail?bizOrderId=' + orderId }; } function looksLikeDetail(json) { return !!json && typeof json === 'object' && (json.mainOrder || json.tabs || json.detailExtra || json.orderBar) && !json.error; } async function fetchDetail(orderId) { if (detailCache.has(orderId)) return detailCache.get(orderId); const p = (async () => { let lastErr = '未知错误'; const tryCombo = async (idx) => { const combo = DETAIL_COMBOS[idx]; const text = await gmRequest({ method: 'GET', url: combo.url(encodeURIComponent(orderId)), headers: combo.qn ? qnHeaders(orderId) : baseHeaders() }); const json = parseJsonSafe(text); if (!looksLikeDetail(json)) { lastErr = '接口返回的不是有效详情(掉登录/风控/换了店铺类型)'; return null; } const addrStr = findAddressStr(json); const r = splitAddress(addrStr); r.ok = !!addrStr; r.memo = extractMemo(json); r.failed = false; r.variant = combo.tag; if (!addrStr) r.raw = '接口未返回收货地址(可能已隐藏或无权限)'; return r; }; // 1) 已确定过就用确定的那个 if (detailCombo >= 0) { try { const r = await tryCombo(detailCombo); if (r) return r; } catch (e) { lastErr = e.message; } // 换了店铺导致失效 → 重新全量探测 detailCombo = -1; } // 2) 全量探测 for (let i = 0; i < DETAIL_COMBOS.length; i++) { try { const r = await tryCombo(i); if (r) { detailCombo = i; return r; } } catch (e) { lastErr = e.message; } } return { ok: false, failed: true, raw: '获取失败:' + lastErr, memo: '', name: '', phone: '', detail: '', zip: '' }; })(); detailCache.set(orderId, p); return p; } /* ========================= 4. 并发控制 ========================= */ async function mapLimit(items, limit, worker) { const queue = items.slice(); const n = Math.max(1, Math.min(limit, queue.length)); const runners = []; for (let i = 0; i < n; i++) { runners.push((async () => { while (queue.length) { const item = queue.shift(); try { await worker(item); } catch (e) { /* 单个失败不中断 */ } } })()); } await Promise.all(runners); } /* ========================= 匹配 ========================= */ function parseKeywords(raw) { return raw.split(/[\n,,;;\s]+/).map(s => s.trim()).filter(Boolean); } function matchOrders(orders, attrName, keywords, mode) { const noFilter = !keywords.length; const out = []; orders.forEach(order => { const hits = []; (order.subOrders || []).forEach(sub => { const info = sub.itemInfo || {}; const sku = info.skuText || []; const target = sku.find(s => String(s && s.name || '').trim() === attrName); let val = ''; if (target) val = String(target.value || '').trim(); // 无关键词:不过滤,把每个子订单都收进来 if (!noFilter) { if (!target) return; if (!keywords.find(k => mode === 'exact' ? val === k : val.indexOf(k) > -1)) return; } const pick = (n) => { const f = sku.find(s => String(s && s.name || '').trim() === n); return f ? String(f.value || '') : ''; }; hits.push({ color: val, size: pick('尺码'), sleeve: pick('袖长'), qty: sub.quantity || '', price: (sub.priceInfo && sub.priceInfo.realTotal) || '', subId: String(sub.idStr || sub.id || ''), title: info.title || '' }); }); if (hits.length) { out.push({ id: String(order.id || (order.orderInfo && order.orderInfo.id) || ''), createTime: (order.orderInfo && order.orderInfo.createTime) || '', buyer: (order.buyer && (order.buyer.nick || order.buyer.encodeNick)) || '', actualFee: (order.payInfo && order.payInfo.actualFee) || '', statusText: (order.statusInfo && order.statusInfo.text) || '', hits: hits, msg: null, memo: null, addr: null }); } }); out.all = noFilter; return out; } /* ========================= 界面 ========================= */ GM_addStyle(` #sku-finder-panel * { box-sizing: border-box; font-family: -apple-system, "Microsoft YaHei", "PingFang SC", sans-serif; } #sku-finder-panel { position: fixed; top: 12px; right: 12px; width: 900px; min-width: 430px; max-width: 96vw; max-height: calc(100vh - 24px); display: flex; flex-direction: column; z-index: 2147483647; background: #ffffff; color: #1f2328; border: 1px solid #d9dee5; border-radius: 10px; box-shadow: 0 10px 34px rgba(15,23,42,.18); font-size: 13px; line-height: 1.45; overflow: hidden; } #sku-finder-panel.collapsed #sf-body { display: none; } #sf-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; background: linear-gradient(90deg,#ff6a00,#ff9500); color:#fff; font-weight: 600; font-size: 13px; cursor: move; user-select: none; } /* flex 链路:panel → #sf-body → #sf-result → .sf-tablewrap 每一层都要 min-height:0,否则 flex 子项不会收缩,内部滚动条永远不生效 */ #sf-body { flex: 1 1 auto; min-height: 0; padding: 9px 11px 11px; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; } #sf-resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: ew-resize; z-index: 5; background: linear-gradient(135deg, transparent 0 45%, #c6cdd6 45% 55%, transparent 55% 70%, #c6cdd6 70% 80%, transparent 80%); } /* 第一行:输入框 + 选择框 */ .sf-fieldline { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; } .sf-field { display: flex; flex-direction: column; gap: 2px; flex: 1 1 92px; min-width: 0; } .sf-field.f-key { flex: 2.8 1 230px; } .sf-lab { font-size: 11px; color: #5a6572; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #sku-finder-panel input[type=text], #sku-finder-panel textarea, #sku-finder-panel select { width: 100%; border: 1px solid #ccd3dc; border-radius: 5px; padding: 4px 6px; font-size: 12px; color:#1f2328; background:#fff; outline: none; height: 27px; } #sku-finder-panel textarea { height: 27px; min-height: 27px; resize: vertical; line-height: 1.25; } #sku-finder-panel input:focus, #sku-finder-panel textarea:focus, #sku-finder-panel select:focus { border-color:#ff6a00; box-shadow:0 0 0 2px rgba(255,106,0,.15); } #sku-finder-panel input[type=checkbox] { width: 13px; height: 13px; margin: 0; vertical-align: -2px; } /* 第二行:勾选框 */ .sf-checkline { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; font-size: 12px; color:#42506b; padding: 7px 2px; margin-top: 7px; border-top: 1px solid #eef1f5; border-bottom: 1px solid #eef1f5; } .sf-checkline label { display: inline-flex; align-items: center; gap: 4px; margin: 0; white-space: nowrap; cursor: pointer; } .sf-checkline .sf-note { margin-left: auto; color: #9aa2ad; font-size: 11px; } /* 第三行:按钮 */ .sf-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; } .sf-btn { flex: 1 1 auto; border: 0; border-radius: 6px; padding: 7px 12px; font-size: 13px; cursor: pointer; background: #ff6a00; color: #fff; font-weight: 600; white-space: nowrap; } .sf-btn:hover { background:#e85f00; } .sf-btn:disabled { background:#f0b98d; cursor: not-allowed; } .sf-btn-ghost { background:#f2f4f7; color:#424a53; font-weight: 500; border: 1px solid #dfe4ea; } .sf-btn-ghost:hover { background:#e6eaef; } .sf-btn-xlsx { background:#1a7f37; } .sf-btn-xlsx:hover { background:#166b2e; } .sf-btn-mini { flex: 0 0 auto; padding: 2px 8px; font-size: 12px; border-radius: 5px; border: 1px solid #fff; background: transparent; color:#fff; cursor: pointer; } #sf-status { margin-top: 8px; font-size: 12px; color: #6b7280; min-height: 17px; } #sf-sum { margin-top: 2px; font-size: 12px; color:#30363d; font-weight: 600; } .sf-empty { color:#9aa2ad; text-align:center; padding: 18px 0; } /* 结果表格:列和导出的 XLSX 完全一致 */ #sf-result { margin-top: 8px; flex: 1 1 auto; min-height: 140px; display: flex; flex-direction: column; overflow: hidden; } .sf-tablewrap { flex: 1 1 auto; min-height: 0; overflow: auto; border: 1px solid #e3e8ef; border-radius: 7px; background: #fff; overscroll-behavior: contain; } /* 细滚动条,明确提示可滚 */ .sf-tablewrap::-webkit-scrollbar { width: 11px; height: 11px; } .sf-tablewrap::-webkit-scrollbar-track { background: #f6f8fa; border-radius: 6px; } .sf-tablewrap::-webkit-scrollbar-thumb { background: #c2cad4; border-radius: 6px; border: 2px solid #f6f8fa; } .sf-tablewrap::-webkit-scrollbar-thumb:hover { background: #98a2af; } .sf-tablewrap::-webkit-scrollbar-corner { background: #f6f8fa; } table.sf-table { border-collapse: separate; border-spacing: 0; font-size: 12px; table-layout: fixed; } .sf-table th { position: sticky; top: 0; z-index: 2; background: #f6f8fa; color:#424a53; font-weight: 600; text-align: left; padding: 6px 7px; border-bottom: 1px solid #d9dee5; white-space: nowrap; font-size: 11.5px; } .sf-table td { padding: 5px 7px; border-bottom: 1px solid #eef1f5; vertical-align: top; word-break: break-all; } .sf-table tbody tr:nth-child(even) { background: #fcfdfe; } .sf-table tbody tr:hover { background: #fff7ef; } .sf-table td.c-oid { color:#d9480f; font-weight: 600; cursor: pointer; } .sf-table td.c-oid:hover { text-decoration: underline; } .sf-table td.c-num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .sf-table td.c-copy { cursor: pointer; } .sf-table td.c-copy:hover { text-decoration: underline; } .sf-table td.c-msg { color:#0969da; } .sf-table td.c-memo { color:#8250df; } .sf-table td.c-addr { color:#1a7f37; cursor: pointer; } .sf-table td.c-addr:hover { text-decoration: underline; } .sf-table td.c-dim { color:#9aa2ad; } .sf-table td.c-err { color:#d92c2c; } .sf-detail { margin-top: 7px; font-size: 11px; color:#8b949e; line-height: 1.5; } `); const panel = document.createElement('div'); panel.id = 'sku-finder-panel'; panel.innerHTML = `
SKU 订单查找 · 导出 XLSX
目标属性值(逗号/换行分隔,多个是「或」关系)
属性名
匹配方式
订单范围
每页条数
最多抓几页
翻页间隔ms
并发
详情接口:待探测
就绪。
表格列与导出的 XLSX 完全一致,一笔多件自动拆行。表格区域可上下 / 左右滚动查看全部行与列;订单号、收货地址单元格点击即可复制。右下角可拖动调整面板宽度。
`; document.body.appendChild(panel); // 默认折叠 panel.classList.add('collapsed'); panel.querySelector('#sf-collapse').textContent = '展开'; const $ = (sel) => panel.querySelector(sel); const elStatus = $('#sf-status'), elSum = $('#sf-sum'), elResult = $('#sf-result'), btnRun = $('#sf-run'), btnXlsx = $('#sf-xlsx'); const tabSel = $('#sf-tab'); TAB_OPTIONS.forEach(t => { const o = document.createElement('option'); o.value = t.code; o.textContent = t.label; o.dataset.status = t.status; tabSel.appendChild(o); }); let lastResult = []; let lastAttr = '颜色分类'; let COLS = []; /* 拖动 + 收起 */ (function () { const head = $('#sf-head'); let dragging = false, sx = 0, sy = 0, ox = 0, oy = 0; head.addEventListener('mousedown', (e) => { if (e.target.tagName === 'BUTTON') return; dragging = true; sx = e.clientX; sy = e.clientY; const r = panel.getBoundingClientRect(); ox = r.right; oy = r.top; panel.style.left = 'auto'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; const top = Math.max(4, oy + (e.clientY - sy)); panel.style.right = Math.max(4, window.innerWidth - (ox + (e.clientX - sx))) + 'px'; panel.style.top = top + 'px'; // 面板往下拖时同步收紧最大高度,保证底部工具栏和表格滚动条始终在视口内 panel.style.maxHeight = Math.max(220, window.innerHeight - top - 12) + 'px'; }); document.addEventListener('mouseup', () => { dragging = false; }); })(); // 窗口尺寸变化时同步(面板被拖到下半屏时尤其重要) window.addEventListener('resize', () => { const top = parseFloat(panel.style.top || '12') || 12; panel.style.maxHeight = Math.max(220, window.innerHeight - top - 12) + 'px'; }); $('#sf-collapse').addEventListener('click', () => { const c = panel.classList.toggle('collapsed'); $('#sf-collapse').textContent = c ? '展开' : '收起'; }); function setStatus(msg, isErr) { elStatus.textContent = msg; elStatus.style.color = isErr ? '#d92c2c' : '#6b7280'; } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function copy(text, tip) { try { GM_setClipboard(text, 'text/plain'); } catch (e) { } try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } catch (e) { } if (tip) setStatus(tip); } /* ===================== 表格化结果 ===================== */ // 列定义必须与 buildRows/header 一一对应(XLSX 用同一份顺序) function makeCols() { return [ { t: '订单号', w: 132, key: 'oid', cls: 'c-oid', get: (it) => it.id }, { t: '下单时间', w: 118, get: (it) => it.createTime }, { t: '订单状态', w: 116, get: (it) => it.statusText }, { t: '买家', w: 70, get: (it) => it.buyer }, { t: '属性(' + lastAttr + ')', w: 124, get: (it, h) => h.color }, { t: '尺码', w: 60, get: (it, h) => h.size }, { t: '数量', w: 46, cls: 'c-num', get: (it, h) => h.qty }, { t: '单价', w: 62, cls: 'c-num', get: (it, h) => h.price }, { t: '客户留言', w: 170, get: (it) => msgCell(it) }, { t: '客服备注', w: 190, get: (it) => memoCell(it) }, { t: '收件人', w: 80, get: (it) => it.addr && it.addr.ok ? it.addr.name : addrEmpty(it) }, { t: '电话', w: 132, get: (it) => it.addr && it.addr.ok ? it.addr.phone : addrEmpty(it) }, { t: '收货地址', w: 300, key: 'addr', cls: 'c-addr', get: (it) => it.addr && it.addr.ok ? (it.addr.detail || it.addr.raw) : addrEmpty(it) }, { t: '邮编', w: 58, get: (it) => it.addr && it.addr.ok ? it.addr.zip : addrEmpty(it) } ]; } function addrEmpty(it) { if (!it.addr) return { v: '点击「补全」', cls: 'c-dim' }; if (it.addr._pending) return { v: '加载中…', cls: 'c-dim' }; return { v: it.addr.raw || '获取失败', cls: 'c-err' }; } function msgCell(it) { if (!it.msg) return { v: '点击「补全」', cls: 'c-dim' }; if (it.msg._pending) return { v: '加载中…', cls: 'c-dim' }; if (!it.msg.ok) return { v: it.msg.text, cls: 'c-err' }; return { v: it.msg.empty ? '(无)' : it.msg.text, cls: it.msg.empty ? 'c-dim' : 'c-msg' }; } function memoCell(it) { if (!it.memo) return { v: '点击「补全」', cls: 'c-dim' }; if (it.memo._pending) return { v: '加载中…', cls: 'c-dim' }; if (!it.memo.ok) return { v: it.memo.text, cls: 'c-err' }; return { v: it.memo.text || '(空)', cls: it.memo.text ? 'c-memo' : 'c-dim' }; } function norm(v) { return (v && typeof v === 'object') ? v : { v: v == null ? '' : String(v), cls: '' }; } function applyRow(tr, item, h) { const cols = COLS; let html = ''; for (let i = 0; i < cols.length; i++) { const c = cols[i]; const r = norm(c.get(item, h)); const isCopy = (c.key === 'oid') || (c.key === 'addr' && r.cls !== 'c-err' && r.cls !== 'c-dim'); const extra = isCopy ? ' c-copy' : ''; html += '' + esc(r.v) + ''; } tr.innerHTML = html; } function refreshItem(item) { if (!item._rows) return; for (let i = 0; i < item._rows.length; i++) applyRow(item._rows[i], item, item.hits[i]); } function renderResult(list) { lastResult = list; COLS = makeCols(); elResult.innerHTML = ''; if (!list.length) { elResult.innerHTML = '
没有找到匹配的订单
'; return; } const totalHit = list.reduce((n, o) => n + o.hits.length, 0); const isAll = list.all === true; elSum.textContent = (isAll ? '未输入关键词,全部加载:' : '命中 ') + list.length + ' 个订单 / ' + totalHit + ' 个子订单(表格 ' + totalHit + ' 行,与导出的 XLSX 列一致)'; const wrap = document.createElement('div'); wrap.className = 'sf-tablewrap'; const table = document.createElement('table'); table.className = 'sf-table'; let html = '' + COLS.map(c => '').join('') + ''; html += '' + COLS.map(c => '' + esc(c.t) + '').join('') + ''; table.innerHTML = html; const tbody = table.querySelector('tbody'); list.forEach(item => { item._rows = []; item.hits.forEach(h => { const tr = document.createElement('tr'); tr.dataset.oid = item.id; item._rows.push(tr); tbody.appendChild(tr); applyRow(tr, item, h); }); }); table.addEventListener('click', onTableClick); wrap.appendChild(table); elResult.appendChild(wrap); } function onTableClick(e) { const td = e.target.closest('td'); if (!td) return; const tr = td.closest('tr'); const idx = parseInt(td.dataset.c, 10); const col = COLS[idx]; if (!col) return; if (col.key === 'oid') { copy(tr.dataset.oid, '订单号已复制:' + tr.dataset.oid); } else if (col.key === 'addr' && td.classList.contains('c-copy')) { copy(td.textContent, '收货地址已复制'); } } const isPending = (x) => x && x._pending === true; async function ensureMsg(item) { if (item.msg && !isPending(item.msg)) return; item.msg = { ok: false, text: '加载中…', _pending: true }; refreshItem(item); item.msg = await fetchMessage(item.id); refreshItem(item); } async function ensureDetail(item) { const needAddr = !item.addr || isPending(item.addr); const needMemo = !item.memo || isPending(item.memo); if (!needAddr && !needMemo) return; item.addr = { ok: false, raw: '加载中…', _pending: true }; item.memo = { ok: false, text: '加载中…', _pending: true }; refreshItem(item); const d = await fetchDetail(item.id); if (d.variant) { const el = $('#sf-variant'); if (el) el.textContent = '详情接口:' + d.variant + '(已适配)'; } if (d.failed) { item.addr = { ok: false, raw: d.raw }; item.memo = { ok: false, text: d.raw }; } else { item.addr = { ok: d.ok, raw: d.raw, name: d.name, phone: d.phone, detail: d.detail, zip: d.zip }; item.memo = { ok: true, text: d.memo || '' }; } refreshItem(item); } async function conc() { return Math.max(1, Math.min(8, parseInt($('#sf-conc').value, 10) || 3)); } async function ensureAll(mode) { const tasks = []; if (mode.msg) tasks.push(ensureMsg); if (mode.addr || mode.memo) tasks.push(ensureDetail); if (!tasks.length) return; const c = await conc(); let done = 0; const total = lastResult.length * tasks.length; await mapLimit(lastResult, c, async (item) => { for (const t of tasks) { await t(item); done++; setStatus('正在补全明细 ' + done + ' / ' + total + '…'); } }); } /* 行构建:一笔多件拆成多行 */ function exportHeader() { return ['订单号', '下单时间', '订单状态', '买家', '属性(' + lastAttr + ')', '尺码', '数量', '单价', '客户留言', '客服备注', '收件人', '电话', '收货地址', '邮编']; } function buildRows() { const rows = []; lastResult.forEach(item => { const a = item.addr || {}; const msgTxt = (item.msg && item.msg.ok && !item.msg.empty) ? item.msg.text : ''; const memoTxt = (item.memo && item.memo.ok) ? item.memo.text : ''; item.hits.forEach(h => { rows.push([ item.id, item.createTime, item.statusText, item.buyer, h.color, h.size, h.qty, h.price, msgTxt, memoTxt, a.name || '', a.phone || '', (a.detail || a.raw || ''), a.zip || '' ]); }); }); return rows; } function tsName() { const d = new Date(); const 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()); } async function doExport() { if (!lastResult.length) { setStatus('没有可导出的结果', true); return; } btnXlsx.disabled = true; try { if ($('#sf-autofill').checked) { await ensureAll({ msg: true, memo: true, addr: true }); } const rows = buildRows(); if (!rows.length) { setStatus('没有数据行可导出', true); return; } const header = exportHeader(); const u8 = buildXlsxBytes([header].concat(rows), [22, 19, 22, 10, 16, 10, 6, 8, 30, 40, 12, 20, 46, 8]); const blob = new Blob([u8], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '订单明细_' + tsName() + '.xlsx'; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 4000); setStatus('已导出 ' + rows.length + ' 行(' + lastResult.length + ' 个订单)。'); } catch (e) { setStatus('导出失败:' + e.message, true); } finally { btnXlsx.disabled = false; } } /* 主流程 */ btnRun.addEventListener('click', async () => { const keywords = parseKeywords($('#sf-keywords').value); const noFilter = !keywords.length; lastAttr = ($('#sf-attr').value || '颜色分类').trim(); const mode = $('#sf-mode').value; const tabCode = tabSel.value; const orderStatus = tabSel.selectedOptions[0].dataset.status || 'PAID'; const pageSize = parseInt($('#sf-pagesize').value, 10) || 15; let maxPages = parseInt($('#sf-maxpage').value, 10); if (!maxPages || maxPages < 1) maxPages = 9999; const interval = Math.max(0, parseInt($('#sf-interval').value, 10) || 0); const force = $('#sf-force').checked; const want = { msg: $('#sf-msg').checked, memo: $('#sf-memo').checked, addr: $('#sf-addr').checked }; btnRun.disabled = true; btnRun.textContent = noFilter ? '加载全部中…' : '查找中…'; elResult.innerHTML = ''; elSum.textContent = ''; try { if (noFilter) setStatus('未输入关键词,将加载所有订单的全部子订单…'); const orders = await loadOrders(tabCode, orderStatus, pageSize, maxPages, interval, force, m => setStatus(m)); const list = matchOrders(orders, lastAttr, keywords, mode); renderResult(list); if (!list.length) { setStatus(noFilter ? '当前订单范围为空。' : '匹配结束,无结果。可试「包含匹配」,或检查属性名是否正确。'); return; } if (want.msg || want.memo || want.addr) { if (noFilter && list.length > 80) { setStatus('已加载 ' + list.length + ' 单(共 ' + list.reduce((n, o) => n + o.hits.length, 0) + ' 行)。订单量大,自动补全留言/地址可能很慢,必要时可点「补全缺失数据」逐次拉取,或先取消勾选再查找。'); } else { await ensureAll(want); } } setStatus((noFilter ? '已加载 ' : '命中 ') + list.length + ' 个订单 / ' + list.reduce((n, o) => n + o.hits.length, 0) + ' 个子订单。点「导出 XLSX」生成表格。'); } catch (err) { setStatus('出错了:' + err.message, true); } finally { btnRun.disabled = false; btnRun.textContent = '开始查找'; } }); btnXlsx.addEventListener('click', doExport); $('#sf-clear').addEventListener('click', () => { $('#sf-keywords').value = ''; elResult.innerHTML = ''; elSum.textContent = ''; lastResult = []; setStatus('已清空。'); }); $('#sf-copyids').addEventListener('click', () => { if (!lastResult.length) { setStatus('没有可复制的结果', true); return; } copy(lastResult.map(o => o.id).join('\n'), '已复制 ' + lastResult.length + ' 个订单号'); }); $('#sf-copyall').addEventListener('click', () => { if (!lastResult.length) { setStatus('没有可复制的结果', true); return; } const head = exportHeader().join('\t'); const body = buildRows().map(r => r.join('\t')).join('\n'); copy(head + '\n' + body, '已复制明细(TSV,可直接粘贴进 Excel)'); }); // 补全缺失数据:按当前勾选补齐 $('#sf-fill').addEventListener('click', async () => { if (!lastResult.length) { setStatus('没有可补全的订单', true); return; } btnFill = btnFill || $('#sf-fill'); btnFill.disabled = true; btnFill.textContent = '补全中…'; try { await ensureAll({ msg: $('#sf-msg').checked, memo: $('#sf-memo').checked, addr: $('#sf-addr').checked }); setStatus('补全完成,共 ' + lastResult.length + ' 单。'); } catch (e) { setStatus('补全失败:' + e.message, true); } finally { btnFill.disabled = false; btnFill.textContent = '补全缺失数据'; } }); let btnFill = null; $('#sf-keywords').addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); btnRun.click(); } }); // 右下角拖动调整面板宽度 (function () { const grip = $('#sf-resize'); if (!grip) return; let dragging = false, sx = 0, sw = 0; grip.addEventListener('mousedown', (e) => { dragging = true; sx = e.clientX; sw = panel.offsetWidth; e.preventDefault(); e.stopPropagation(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; const w = Math.max(430, Math.min(window.innerWidth - 20, sw - (e.clientX - sx))); panel.style.width = w + 'px'; }); document.addEventListener('mouseup', () => { dragging = false; }); })(); console.log('[SKU 订单查找] v3.0 已加载,表格化结果,面板在页面右上角。'); })();