// ==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 已加载,表格化结果,面板在页面右上角。');
})();