// ==UserScript==
// @name 阳光智链市场热图订单列表
// @namespace https://easymoo.com/ssc-helper
// @version 0.3.0
// @description 在阳光智链市场热图页按当前产品和月份展示发标城市、数量与客户信息
// @author Codex
// @match https://www.easymoo.com/ssc-race*/view/pro-detail-heatmap*
// @match http://www.easymoo.com/ssc-race*/view/pro-detail-heatmap*
// @grant GM_xmlhttpRequest
// @connect www.easymoo.com
// ==/UserScript==
(function () {
'use strict';
const PANEL_ID = 'ssc-heatmap-order-panel';
const STYLE_ID = 'ssc-heatmap-order-style';
const MONTH_GROUP_SELECTOR = '#month-group';
const MONTH_ITEM_SELECTOR = '#month-group .list-group-item';
const SKU_SELECTOR = 'input[name="filterSkuId"]';
const DISPLAY_TYPE_SELECTOR = 'input[name="displayType"]';
const monthNameByDate = {
'2026-01-01': '一月',
'2026-02-01': '二月',
'2026-03-01': '三月',
'2026-04-01': '四月',
'2026-05-01': '五月',
'2026-06-01': '六月',
};
let debounceTimer = null;
let lastRequestKey = '';
let customerLookupCache = null;
let customerLookupKey = '';
let batchExporting = false;
function addStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = #${PANEL_ID} { position: fixed; right: 18px; bottom: 18px; z-index: 1080; width: min(660px, calc(100vw - 36px)); max-height: min(680px, calc(100vh - 36px)); display: flex; flex-direction: column; background: rgba(255, 255, 255, 0.78); color: #1f2933; border: 1px solid #d7dde5; border-radius: 8px; box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16); backdrop-filter: blur(4px); font-size: 13px; line-height: 1.45; overflow: hidden; } #${PANEL_ID}.is-collapsed { width: auto; } #${PANEL_ID}.is-collapsed .ssc-panel-body { display: none; } #${PANEL_ID} .ssc-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; background: rgba(20, 60, 95, 0.82); color: #ffffff; } #${PANEL_ID} .ssc-panel-title { font-weight: 700; white-space: nowrap; } #${PANEL_ID} .ssc-panel-actions { display: flex; gap: 6px; } #${PANEL_ID} button { appearance: none; border: 1px solid rgba(255, 255, 255, 0.55); background: rgba(255, 255, 255, 0.12); color: #ffffff; border-radius: 4px; padding: 2px 7px; cursor: pointer; font-size: 12px; } #${PANEL_ID} .ssc-panel-body { overflow: auto; } #${PANEL_ID} .ssc-summary { padding: 10px 12px; border-bottom: 1px solid #e5e9ef; background: rgba(248, 250, 252, 0.72); } #${PANEL_ID} .ssc-meta { display: grid; grid-template-columns: 74px 1fr; gap: 4px 8px; } #${PANEL_ID} .ssc-meta-label { color: #5d6978; } #${PANEL_ID} .ssc-status { margin-top: 8px; color: #5d6978; } #${PANEL_ID} table { width: 100%; border-collapse: collapse; } #${PANEL_ID} th, #${PANEL_ID} td { padding: 7px 8px; border-bottom: 1px solid #e7ebf0; vertical-align: top; } #${PANEL_ID} th { position: sticky; top: 0; background: rgba(240, 244, 248, 0.86); color: #374151; font-weight: 700; } #${PANEL_ID} td.qty, #${PANEL_ID} th.qty { text-align: right; white-space: nowrap; } #${PANEL_ID} td.customer { min-width: 130px; color: #344054; } #${PANEL_ID} .ssc-empty, #${PANEL_ID} .ssc-error { padding: 16px 12px; } #${PANEL_ID} .ssc-error { color: #b42318; } ;
document.head.appendChild(style);
}
function createPanel() {
let panel = document.getElementById(PANEL_ID);
if (panel) return panel;
panel = document.createElement('section');
panel.id = PANEL_ID;
panel.innerHTML = <div class="ssc-panel-head"> <div class="ssc-panel-title">热图订单城市</div> <div class="ssc-panel-actions"> <button type="button" data-action="export-all">批量导出</button> <button type="button" data-action="refresh">刷新</button> <button type="button" data-action="toggle">收起</button> </div> </div> <div class="ssc-panel-body"> <div class="ssc-summary"> <div class="ssc-meta"> <div class="ssc-meta-label">产品</div><div data-field="sku">-</div> <div class="ssc-meta-label">月份</div><div data-field="month">-</div> <div class="ssc-meta-label">合计</div><div data-field="total">-</div> </div> <div class="ssc-status" data-field="status">等待页面加载...</div> </div> <div data-field="content"></div> </div> ;
document.body.appendChild(panel);
panel.querySelector('[data-action="export-all"]').addEventListener('click', exportAllProductMonths);
panel.querySelector('[data-action="refresh"]').addEventListener('click', () => refreshNow(true));
panel.querySelector('[data-action="toggle"]').addEventListener('click', (event) => {
panel.classList.toggle('is-collapsed');
event.currentTarget.textContent = panel.classList.contains('is-collapsed') ? '展开' : '收起';
});
return panel;
}
function getQueryParam(name) {
return new URLSearchParams(location.search).get(name);
}
function getPublishId() {
return getQueryParam('publishId') || window.PUBLISH_ID;
}
function getRunId() {
return getQueryParam('runId') || window.RUN_ID;
}
function getRaceContextPath() {
const match = location.pathname.match(/^/(ssc-race\d+)//);
return match ? /${match[1]} : '/ssc-race1';
}
function getSelectedSku() {
const checked = document.querySelector(${SKU_SELECTOR}:checked);
if (!checked) return { id: '', name: '所有产品' };
const id = checked.value || '';
const label = checked.closest('label') || checked.parentElement;
const name = label ? label.textContent.replace(/\s+/g, ' ').trim() : '';
return { id, name: name || (id ? id : '所有产品') };
}
function getAllSkus() {
const seen = new Set();
return Array.from(document.querySelectorAll(SKU_SELECTOR)).map((input) => {
const id = input.value || '';
const label = input.closest('label') || input.parentElement;
const name = label ? label.textContent.replace(/\s+/g, ' ').trim() : '';
return { id, name: name || (id ? id : '所有产品') };
}).filter((sku) => {
if (!sku.id || seen.has(sku.id)) return false;
seen.add(sku.id);
return true;
});
}
function getAllMonths() {
const seen = new Set();
return Array.from(document.querySelectorAll(MONTH_ITEM_SELECTOR)).map((item) => {
const start = item.dataset.monthStartDate || '';
const label = monthNameByDate[start] || item.textContent.replace(/\s+/g, ' ').trim() || start;
return { start, end: start, label };
}).filter((month) => {
if (!month.start || seen.has(month.start)) return false;
seen.add(month.start);
return true;
});
}
function getSelectedMonthRange() {
const activeItems = Array.from(document.querySelectorAll(${MONTH_ITEM_SELECTOR}.choose));
const items = activeItems.length ? activeItems : Array.from(document.querySelectorAll(MONTH_ITEM_SELECTOR)).slice(0, 1);
const first = items[0];
const last = items[items.length - 1];
const start = first ? first.dataset.monthStartDate : '';
const end = last ? last.dataset.monthStartDate : start;
const displayType = document.querySelector(${DISPLAY_TYPE_SELECTOR}:checked)?.value || 'month';
let label = '-';
if (start && end && start !== end) {
label = ${monthNameByDate[start] || start} - ${monthNameByDate[end] || end};
} else if (start) {
label = monthNameByDate[start] || first.textContent.replace(/\s+/g, ' ').trim() || start;
}
return { start, end, label, displayType };
}
function setPanelState({ skuName, monthLabel, totalText, status, html }) {
const panel = createPanel();
panel.querySelector('[data-field="sku"]').textContent = skuName || '-';
panel.querySelector('[data-field="month"]').textContent = monthLabel || '-';
panel.querySelector('[data-field="total"]').textContent = totalText || '-';
panel.querySelector('[data-field="status"]').textContent = status || '';
if (typeof html === 'string') {
panel.querySelector('[data-field="content"]').innerHTML = html;
}
}
function postJson(path, payload) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: ${location.origin}${path},
data: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
withCredentials: true,
onload: (response) => {
if (response.status < 200 || response.status >= 300) {
reject(new Error(接口返回 ${response.status}));
return;
}
try {
resolve(JSON.parse(response.responseText || '[]'));
} catch (error) {
reject(new Error('接口返回的不是 JSON,可能登录已失效'));
}
},
onerror: () => reject(new Error('请求失败')),
ontimeout: () => reject(new Error('请求超时')),
timeout: 20000,
});
});
}
function normalizeCountry(row) {
const provinceName = row.provinceName || '';
if (provinceName.startsWith('中国-')) {
return { country: '中国', region: provinceName.slice(3) };
}
return { country: provinceName || '-', region: '' };
}
async function loadCustomerLookup() {
const publishId = getPublishId();
const runId = getRunId();
const cacheKey = JSON.stringify({ publishId, runId });
if (customerLookupCache && customerLookupKey === cacheKey) return customerLookupCache;
if (!publishId || !runId) {
customerLookupCache = { byCityCode: new Map(), byCityName: new Map() };
customerLookupKey = cacheKey;
return customerLookupCache;
}
const data = await postJson(${getRaceContextPath()}/ppPublish/loadPublishCustomer, {
publishId: Number(publishId),
runId: Number(runId),
});
const partyNameByCode = new Map();
(data.partyQueries || []).forEach((party) => {
if (party.partyCode) partyNameByCode.set(String(party.partyCode), party.partyName || String(party.partyCode));
});
const byCityCode = new Map();
const byCityName = new Map();
(data.cityQueries || []).forEach((city) => {
const partyCode = String(city.partyCode || '');
const partyName = partyNameByCode.get(partyCode) || partyCode;
if (!partyName) return;
if (city.cityCode) addCustomerName(byCityCode, String(city.cityCode), partyName);
const cityNameKey = makeCityNameKey(city);
if (cityNameKey) addCustomerName(byCityName, cityNameKey, partyName);
});
customerLookupCache = { byCityCode, byCityName };
customerLookupKey = cacheKey;
return customerLookupCache;
}
function addCustomerName(map, key, partyName) {
const names = map.get(key) || [];
if (!names.includes(partyName)) names.push(partyName);
map.set(key, names);
}
function makeCityNameKey(row) {
const province = row.provinceCode || row.provinceName || '';
const city = row.cityName || '';
return city ? ${province}::${city} : '';
}
function getCustomerNames(row, lookup) {
if (!lookup) return [];
if (row.cityCode && lookup.byCityCode.has(String(row.cityCode))) {
return lookup.byCityCode.get(String(row.cityCode));
}
const cityNameKey = makeCityNameKey(row);
if (cityNameKey && lookup.byCityName.has(cityNameKey)) {
return lookup.byCityName.get(cityNameKey);
}
return [];
}
function renderRows(rows, customerLookup) {
if (!rows.length) return '
${loc.country}-${loc.region} : loc.country;
const qty = Number(row.quantity || 0);
const customers = getCustomerNames(row, customerLookup).join('、') || '-';
return <tr> <td>${escapeHtml(place)}</td> <td>${escapeHtml(row.cityName || '-')}</td> <td class="customer">${escapeHtml(customers)}</td> <td class="qty">${formatNumber(qty)}</td> </tr>;
}).join('');
return <table> <thead><tr><th>国家/地区</th><th>城市</th><th>客户信息</th><th class="qty">数量</th></tr></thead> <tbody>${body}</tbody> </table>;
}
function buildExportRows(rows, customerLookup, meta) {
return rows.slice().sort((a, b) => {
const ca = normalizeCountry(a);
const cb = normalizeCountry(b);
return ca.country.localeCompare(cb.country, 'zh-Hans-CN') ||
ca.region.localeCompare(cb.region, 'zh-Hans-CN') ||
String(a.cityName || '').localeCompare(String(b.cityName || ''), 'zh-Hans-CN');
}).map((row) => {
const loc = normalizeCountry(row);
return {
country: loc.country,
region: loc.region,
city: row.cityName || '-',
customer: getCustomerNames(row, customerLookup).join('、') || '-',
productMonth: getHeatmapProductMonth(row, meta),
skuId: meta?.skuId || '-',
monthDate: meta?.monthDate || '-',
longitude: row.longitude ?? '',
latitude: row.latitude ?? '',
quantity: Number(row.quantity || 0),
};
});
}
async function exportAllProductMonths() {
if (batchExporting) return;
const publishId = getPublishId();
const skus = getAllSkus();
const months = getAllMonths();
const panel = createPanel();
const status = panel.querySelector('[data-field="status"]');
if (!publishId || !skus.length || !months.length) {
status.textContent = '没有识别到产品或月份,无法批量导出。';
return;
}
batchExporting = true;
const button = panel.querySelector('[data-action="export-all"]');
button.disabled = true;
button.textContent = '导出中';
try {
const customerLookup = await loadCustomerLookup();
const allRows = [];
const totalTasks = skus.length * months.length;
let doneTasks = 0;
for (const sku of skus) {
for (const month of months) {
doneTasks += 1;
status.textContent = 批量导出中 ${doneTasks}/${totalTasks}:${sku.name} ${month.label};
const rows = await postJson(${getRaceContextPath()}/execute/loadCityTender, {
publishId: Number(publishId),
startMonthDate: month.start,
endMonthDate: month.end,
skuId: sku.id,
});
const meta = {
skuId: sku.id,
skuName: sku.name,
monthLabel: month.label,
monthDate: month.start,
};
allRows.push(...buildExportRows(rows, customerLookup, meta).map((row) => ({
...row,
skuName: sku.name,
monthLabel: month.label,
})));
}
}
downloadCsv('阳光智链_全部产品月份发标城市.csv', buildCsvRows(allRows));
status.textContent = 批量导出完成:${skus.length} 个产品 × ${months.length} 个月,共 ${allRows.length} 条城市数据。;
} catch (error) {
status.textContent = 批量导出失败:${error.message || error};
} finally {
batchExporting = false;
button.disabled = false;
button.textContent = '批量导出';
}
}
function buildCsvRows(rows, meta) {
const header = ['产品', '产品ID', '月份', '月份日期', '产品月份', '国家', '地区', '城市', '客户信息', '数量', '经度', '纬度'];
return [header].concat(rows.map((row) => [
row.skuName || meta?.skuName || '',
row.skuId,
row.monthLabel || meta?.monthLabel || '',
row.monthDate,
row.productMonth,
row.country,
row.region,
row.city,
row.customer,
row.quantity,
row.longitude,
row.latitude,
]));
}
function downloadCsv(fileName, csvRows) {
const csv = \ufeff${csvRows.map((row) => row.map(csvCell).join(',')).join('\n')};
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function csvCell(value) {
const text = String(value ?? '');
return "${text.replace(/"/g, '""')}";
}
function getHeatmapProductMonth(row, meta) {
const directValue = firstText(row, [
'productMonth',
'productMonthName',
'skuMonth',
'skuMonthName',
'tenderMonthName',
'monthProductName',
]);
if (directValue) return directValue;
const product = firstText(row, [
'skuName',
'productName',
'product',
'goodsName',
'materialName',
]) || meta?.skuName || '';
const month = formatHeatmapMonth(firstText(row, [
'monthName',
'tenderMonth',
'month',
'monthDate',
'monthStartDate',
'startMonthDate',
'bidMonth',
])) || meta?.monthDate || meta?.monthLabel || '';
return ${product || '-'} ${month || '-'}.trim();
}
function firstText(source, keys) {
if (!source) return '';
for (const key of keys) {
const value = source[key];
if (value !== undefined && value !== null && String(value).trim() !== '') {
return String(value).replace(/\s+/g, ' ').trim();
}
}
return '';
}
function formatHeatmapMonth(value) {
if (!value) return '';
return monthNameByDate[value] || value;
}
function escapeHtml(value) {
return String(value).replace(/[&<>"]/g, (char) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
}[char]));
}
function formatNumber(value) {
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value);
}
async function refreshNow(force) {
const publishId = getPublishId();
const sku = getSelectedSku();
const month = getSelectedMonthRange();
if (!publishId || !month.start || !month.end) {
setPanelState({
skuName: sku.name,
monthLabel: month.label,
totalText: '-',
status: '没有识别到 publishId 或月份,请确认在市场热图页。',
html: '${getRaceContextPath()}/execute/loadCityTender, {
publishId: Number(publishId),
startMonthDate: month.start,
endMonthDate: month.end,
skuId: sku.id,
}),
loadCustomerLookup(),
]);
const total = rows.reduce((sum, row) => sum + Number(row.quantity || 0), 0);
setPanelState({
skuName: sku.name,
monthLabel: month.label,
totalText: ${formatNumber(total)} 箱 / ${rows.length} 城市,
status: 已按当前热图选择更新。,
html: renderRows(rows, customerLookup),
});
} catch (error) {
setPanelState({
skuName: sku.name,
monthLabel: month.label,
totalText: '-',
status: '读取失败',
html: <div class="ssc-error">${escapeHtml(error.message || error)}</div>,
});
}
}
function scheduleRefresh(force) {
window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => refreshNow(force), 250);
}
function bindEvents() {
document.addEventListener('change', (event) => {
if (event.target.matches(${SKU_SELECTOR}, ${DISPLAY_TYPE_SELECTOR}, input[name="showSupplier"])) {
scheduleRefresh(true);
}
}, true);
document.addEventListener('click', (event) => {
if (event.target.closest(MONTH_ITEM_SELECTOR)) {
scheduleRefresh(true);
}
}, true);
const observer = new MutationObserver(() => scheduleRefresh(false));
const monthGroup = document.querySelector(MONTH_GROUP_SELECTOR);
if (monthGroup) {
observer.observe(monthGroup, { attributes: true, subtree: true, attributeFilter: ['class'] });
}
}
function startWhenReady() {
addStyle();
createPanel();
bindEvents();
scheduleRefresh(true);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startWhenReady);
} else {
startWhenReady();
}
})();