// ==UserScript==
// @name Abing 报表抓取 (v37.0 核心逻辑终极修正版)
// @namespace http://tampermonkey.net/
// @version 37.0
// @description 修复报表数据引用层级错误(彻底解决数据0问题);查询面板补全手机号显示;保留严格URL与自动监控
// @author Senior Engineer
// @match *://*.abingxin.cn/*
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_setClipboard
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant unsafeWindow
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
const nativeIDB = unsafeWindow.indexedDB || window.indexedDB;
const DB_NAME = 'AbingReportDB';
const DB_VERSION = 3;
if (!nativeIDB) { alert("不支持 IndexedDB"); return; }
const toast = (msg, color='green') => {
const d = document.createElement('div');
d.style.cssText = `position:fixed;top:10px;right:10px;background:${color};color:white;padding:8px 15px;z-index:2147483647;border-radius:4px;box-shadow:0 2px 10px rgba(0,0,0,0.2);font-size:13px;`;
d.innerText = msg;
document.body.appendChild(d);
setTimeout(() => d.remove(), 3000);
};
// ==========================================
// 1. 数据库模块
// ==========================================
const DB = {
db: null,
init: function() {
return new Promise((resolve, reject) => {
if (this.db) return resolve(this.db);
const req = nativeIDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('orders')) {
const store = db.createObjectStore('orders', { keyPath: 'orderId' });
store.createIndex('date', 'createTimeRaw', { unique: false });
}
if (!db.objectStoreNames.contains('dailyReports')) {
db.createObjectStore('dailyReports', { keyPath: 'date' });
}
};
req.onsuccess = (e) => { this.db = e.target.result; resolve(this.db); };
req.onerror = (e) => reject("DB Error");
});
},
saveOrders: function(list) {
return new Promise((resolve, reject) => {
if(!this.db) return reject("DB未连接");
const tx = this.db.transaction(['orders'], 'readwrite');
const store = tx.objectStore('orders');
let count = 0;
list.forEach(item => { if(item && item.orderId) { store.put(item); count++; } });
tx.oncomplete = () => resolve(count);
tx.onerror = (e) => reject(e.target.error);
});
},
updateRemark: function(id, text) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(['orders'], 'readwrite');
const store = tx.objectStore('orders');
const req = store.get(id);
req.onsuccess = () => {
const data = req.result;
if(data) { data.remark = text; store.put(data); resolve(true); }
else reject("未找到订单");
};
});
},
saveReportData: function(dataObj) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(['dailyReports'], 'readwrite');
tx.objectStore('dailyReports').put(dataObj);
tx.oncomplete = () => resolve();
tx.onerror = (e) => reject(e.target.error);
});
},
getAllOrders: function() {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(['orders'], 'readonly');
const req = tx.objectStore('orders').getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject("查询失败");
});
},
getAllReports: function() {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(['dailyReports'], 'readonly');
const req = tx.objectStore('dailyReports').getAll();
req.onsuccess = () => {
const raw = req.result || [];
const clean = raw.map(r => r.data ? { date: r.date, ...r.data } : r);
resolve(clean);
};
req.onerror = () => reject("查询日报失败");
});
},
reset: function() {
return new Promise((resolve) => {
if(this.db) this.db.close();
setTimeout(() => { nativeIDB.deleteDatabase(DB_NAME).onsuccess = resolve; }, 100);
});
}
};
// ==========================================
// 2. 配置与工具
// ==========================================
const DEFAULT_TECHS = ["候冰", "李天港", "华剑豪", "杨永辉", "李振", "李海亮", "秦阿梅", "赵海珍", "李慧萍", "刘莺歌", "何华贵"];
const ConfigManager = {
getTechList: () => GM_getValue('tech_whitelist', DEFAULT_TECHS),
saveTechList: (list) => GM_setValue('tech_whitelist', list),
getShopId: () => GM_getValue('shop_id', '12'),
saveShopId: (id) => GM_setValue('shop_id', id)
};
const Utils = {
cleanName: (str) => str ? str.replace(/\s+/g, '').replace(/[\((].*?[\))]/g, '') : "",
cleanOrderTech: (str) => str ? str.replace(/共享|[12]|\s/g, '').replace(/[\((].*?[\))]/g, '') : "",
parseMoney: (str) => {
if (!str) return 0;
const clean = str.replace(/[¥¥,\s]/g, '');
const match = clean.match(/[-]?\d+(\.\d+)?/);
return match ? parseFloat(match[0]) : 0;
},
parseIntVal: (str) => parseInt(str?.replace(/[,]/g, '') || 0, 10),
parsePercent: (str) => parseFloat(str?.replace(/[%,\s]/g, '') || 0),
getLogicalDate: () => {
const d = new Date();
if (d.getHours() < 3) d.setDate(d.getDate() - 1);
return d;
},
getCurrentMonthRange: () => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const fmt = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
return { start: fmt(start), end: fmt(end) };
}
};
const Network = {
get: (url) => new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET", url,
headers: { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Upgrade-Insecure-Requests": "1" },
onload: res => res.status === 200 ? resolve(res.responseText) : reject("HTTP " + res.status),
onerror: err => reject("Net Error")
});
})
};
const TaskManager = {
getTasks: () => {
const d = Utils.getLogicalDate();
const d1 = new Date(d.getFullYear(), d.getMonth(), 1);
const d2 = new Date(d); d2.setDate(d.getDate() + 1);
const ft = (t) => `${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,'0')}-${String(t.getDate()).padStart(2,'0')}+06%3A00%3A00`;
const fd = (t) => `${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,'0')}-${String(t.getDate()).padStart(2,'0')}`;
const BASE = "https://abingxin.cn";
const SHOP_ID = ConfigManager.getShopId();
let orderListUrl = "https://abingxin.cn/abing-v3/entity?pageNo=1&pageSize=200&sort=desc&sortBy=id&en=Order&target=order%2ForderList&search_EQ_isShow=&search_EQ_shop.id=10&search_EQ_technician.id=&search_EQ_payStatus=1&search_EQ_status=&search_EQ_ordType=&search_LIKE_phoneNum=&search_EQ_member.openid=&search_EQ_sorts=&search_EQ_orderNum=&search_EQ_payAcc=&search_EQ_isdian=&search_EQ_orderWay=&search_GTE_createDt=__START__&search_LTE_createDt=__END__&search_GTE_hhmm=&search_LT_hhmm=&search_EQ_smdd=";
orderListUrl = orderListUrl.replace("search_GTE_createDt=__START__", `search_GTE_createDt=${ft(d)}`);
orderListUrl = orderListUrl.replace("search_LTE_createDt=__END__", `search_LTE_createDt=${ft(d2)}`);
return [
{ id: 'sale', title: '营业统计', url: `${BASE}/abing-v3/report/saleReport?type=&shopId=${SHOP_ID}&begDt=${ft(d)}&endDt=${ft(d2)}`, parser: Parsers.sale },
{ id: 'work', title: '工时统计', url: `${BASE}/abing-v3/entity?pageNo=1&pageSize=2000&sort=desc&sortBy=dianzhongs&en=TechDayIncomeRep&search_EQ_shopId=${SHOP_ID}&search_EQ_begDt=${ft(d1)}&search_EQ_endDt=${ft(d2)}&search_EQ_techName=&search_EQ_ishb=&search_EQ_isshare=`, parser: Parsers.work },
{ id: 'comm', title: '技师提成', url: `${BASE}/abing-v3/report/orderReport2?sort=desc&sortBy=o.income_amount&shopid=${SHOP_ID}&begdt=${ft(d)}&enddt=${ft(d2)}&tecname=&techType=&btn_submit=`, parser: Parsers.comm },
{ id: 'orders', title: '订单预约列表', url: orderListUrl, parser: Parsers.orders },
{ id: 'fansm', title: '月转卡', url: `${BASE}/abing-v3/entity?pageNo=1&pageSize=100&sort=desc&sortBy=id&en=MemCardFenzRep&search_EQ_shopId=${SHOP_ID}&search_GTE_startTime=${fd(d1)}&search_LTE_startTime=${fd(d2)}`, parser: Parsers.gen },
{ id: 'fansd', title: '日转卡', url: `${BASE}/abing-v3/entity?pageNo=1&pageSize=100&sort=desc&sortBy=id&en=MemCardFenzRep&search_EQ_shopId=${SHOP_ID}&search_GTE_startTime=${fd(d)}&search_LTE_startTime=${fd(d2)}`, parser: Parsers.gen },
{ id: 'new_item', title: '新增订单项目统计', url: `${BASE}/abing-v3/entity?en=OrderItem&search_EQ_payStatus=1&search_GTE_payDt=${ft(d).replace(/\+/g, '%20')}&search_LTE_payDt=${ft(d2).replace(/\+/g, '%20')}&search_EQ_shopId=${SHOP_ID}&search_EQ_item.itemType.id=5`, parser: Parsers.gen }
];
}
};
// ==========================================
// 3. 解析器
// ==========================================
const Parsers = {
gen: (html) => {
const doc = new DOMParser().parseFromString(html, "text/html");
const res = [];
let tables = Array.from(doc.querySelectorAll('table'));
if (!tables.length) return { data: [] };
tables.sort((a, b) => b.rows.length - a.rows.length);
const rows = Array.from(tables[0].rows);
if(rows.length<1) return {data:[]};
const headers = Array.from(rows[0].cells).map(c => c.innerText.trim());
for(let i=1; i<rows.length; i++) {
const obj = {};
Array.from(rows[i].cells).forEach((c, idx) => { if(headers[idx]) obj[headers[idx]] = c.innerText.trim(); });
if(Object.keys(obj).length>0) res.push(obj);
}
return { data: res };
},
sale: (html) => {
const doc = new DOMParser().parseFromString(html, "text/html");
const data = [];
const parseTbl = (sel) => {
const t = doc.querySelector(sel + ' table');
if(!t) return;
Array.from(t.querySelectorAll('tbody tr')).forEach(tr => {
const tds = tr.cells;
if(tds.length < 2) return;
const name = tds[0].innerText.trim();
const val1 = tds[1].innerText.trim();
const val2 = tds[2] ? tds[2].innerText.trim() : "";
let num = 0;
if(name.includes('营业人数') || name.includes('项目总数')) num = Utils.parseMoney(val1);
else if(val1.includes('/')) num = Utils.parseIntVal(val1.split('/')[0]);
else num = Utils.parseIntVal(val1);
data.push({ "项目": name, "数量": num, "金额": Utils.parseMoney(val2) });
});
};
parseTbl('.doc1'); parseTbl('.doc2');
return { data: data };
},
work: (html) => {
const res = Parsers.gen(html);
res.data.forEach(r => {
const k = r['技师']?'技师':(r['姓名']?'姓名':'技师姓名');
if(r[k]) r[k] = Utils.cleanName(r[k]);
delete r['门店'];
});
return res;
},
comm: (html) => {
const res = Parsers.gen(html);
res.data.forEach(r => {
const k = r['技师姓名']?'技师姓名':'技师';
if(r[k]) r[k] = Utils.cleanName(r[k]);
delete r['所属分店'];
});
return res;
},
orders: (html) => {
const doc = new DOMParser().parseFromString(html, "text/html");
const res = [];
const rows = doc.querySelectorAll('#DataTables_Table_0 tbody tr');
const BASE = "https://abingxin.cn";
const currentYear = new Date().getFullYear();
if(!rows || rows.length === 0) return { data: [] };
rows.forEach(tr => {
if(tr.cells.length < 7) return;
const c = tr.cells;
const phone = c[1].querySelector('.phoneNum')?.getAttribute('data-phone') || "";
const idLink = c[2].querySelector('a');
const oid = idLink ? idLink.innerText.trim() : "";
let url = "";
if(idLink) {
const m = idLink.getAttribute('onclick')?.match(/to_show\('[^']+',\s*'([^']+)'/);
if(m) url = BASE + m[1];
}
const techText = c[3].innerText;
const techName = c[3].querySelector('a[onclick*="Technician"]')?.innerText.trim() || "";
const isDian = techText.includes('点钟') ? "是" : "否";
let category = "";
c[3].querySelectorAll('p').forEach(p=>{ if(p.innerText.includes('类别:')) category = p.innerText.replace('类别:','').trim(); });
const item = c[3].querySelector('a[data-txt]')?.getAttribute('data-txt') || "";
const amt = parseFloat(c[4].innerText.match(/订单金额:([\d\.]+)/)?.[1] || 0);
const timeTxt = c[5].innerText;
let createT = timeTxt.match(/下单时间:([^\n]+)/)?.[1] || "";
if(createT && !createT.startsWith('20')) createT = `${currentYear}-${createT.trim()}`;
const servT = timeTxt.match(/服务时间:([^\n]+)/)?.[1] || "";
const occT = timeTxt.match(/占用时间:([^\n]+)/)?.[1] || "";
const statusTxt = c[6].innerText;
const payS = statusTxt.includes('已支付') ? "已支付" : "未支付";
let revS = "未评";
c[6].querySelectorAll('p').forEach(p=>{ if(p.innerText.includes('评价状态')) revS = p.innerText.replace('评价状态:','').trim(); });
res.push({
orderId: oid, phone: phone, url: url,
techName: Utils.cleanOrderTech(techName), itemName: item, category: category,
amount: amt, isDian: isDian, payStatus: payS, reviewStatus: revS,
createTimeRaw: createT.trim(), serviceTime: servT.trim(), occupyTime: occT.trim()
});
});
return { data: res };
},
generic: (html) => Parsers.gen(html)
};
// ==========================================
// 4. 核心逻辑 (修复:修正变量引用)
// ==========================================
const Core = {
generateReportData: (finalData) => {
const techs = ConfigManager.getTechList();
console.log('[2] finalData['技师提成']);
// 修复:直接使用 finalData[...],不带 .data
const comm = finalData['技师提成'] || [];
const work = finalData['工时统计'] || [];
const recMap = {};
comm.forEach(r => {
const n = r['技师姓名'] || r['技师'];
if(n) recMap[n] = Utils.parseIntVal(r['接待人数']);
});
work.forEach(r => {
const n = r['技师'] || r['姓名'];
r['当天接待人数'] = (n && recMap[n]>=1) ? recMap[n] : 0;
});
const date = Utils.getLogicalDate();
// 修复:直接使用 finalData['营业统计']
const sale = finalData['营业统计'] || [];
const getSale = (k) => {
const f = sale.find(r => r['项目'] && r['项目'].includes(k));
return f ? { c: f['数量'], a: f['金额'] } : { c:0, a:0 };
};
const numCust = getSale('营业人数').c;
const amtRecharge = getSale('充卡').a;
const numJingluo = getSale('经络').c;
let okCount = 0;
const targetWork = work.filter(r => techs.includes(r['技师']||r['姓名']));
targetWork.forEach(r => { if(Utils.parsePercent(r['点钟率']) >= 30) okCount++; });
const rate = techs.length ? ((okCount/techs.length)*100).toFixed(2) : "0.00";
let fanDay = 0;
(finalData['日转卡'] || []).forEach(r => fanDay += Utils.parseIntVal(r['转换数量']));
let fanRateMonth = "0.00";
const monthList = finalData['月转卡'] || [];
if (monthList.length > 0) fanRateMonth = monthList[0]['充卡率'] || "0.00";
let s1=0, s2=0;
work.forEach(r => {
if(r['当天接待人数'] >= 1) {
const shareStatus = r['是否共享'] || "";
if(shareStatus.includes('共享1子')) s1++;
if(shareStatus.includes('共享2子')) s2++;
}
});
return {
date: Utils.getLogicalDate().toISOString().split('T')[0],
data: { numCust, rate, amtRecharge, fanDay, fanRateMonth, numJingluo, s1, s2 }
};
},
formatReportText: (reportObj) => {
const d = reportObj.data;
const dateParts = reportObj.date.split('-');
const dateStr = `${parseInt(dateParts[1])}月${parseInt(dateParts[2])}日`;
return `祥盛街店${dateStr}
1:人数: ${d.numCust || 0}
2:截止今日点钟合格率${d.rate || "0.00"}%
3:充值 ${d.amtRecharge || 0}
4:今天新客转卡:${d.fanDay || 0}张,截止今天转卡率:${d.fanRateMonth || "0.00"}
5:经络: ${d.numJingluo || 0}
共享1:(${d.s1 || 0})
共享2:(${d.s2 || 0} )
6:富贵包调理:0个
7:富贵包体验:0个
8:痛经调理:0个`;
}
};
// ==========================================
// 5. 自动监控 (22:30后 严谨URL)
// ==========================================
const AutoMonitor = {
timer: null,
start: () => {
console.log("[Monitor] 启动自动监控...");
if(AutoMonitor.timer) clearInterval(AutoMonitor.timer);
AutoMonitor.timer = setInterval(() => {
const now = new Date();
if (now.getHours() === 22 && now.getMinutes() >= 30 || now.getHours() > 22) {
AutoMonitor.checkUnpaid();
}
}, 60000);
},
checkUnpaid: async () => {
const SHOP_ID = ConfigManager.getShopId();
const url = `https://abingxin.cn/abing-v3/entity?en=Order&target=order/orderList&search_EQ_payStatus=0&search_EQ_shop.id=${SHOP_ID}`;
try {
const html = await Network.get(url);
const doc = new DOMParser().parseFromString(html, "text/html");
const rows = doc.querySelectorAll('#DataTables_Table_0 tbody tr');
let hasData = false;
if(rows.length > 0) {
const firstCell = rows[0].cells[0].innerText;
if(!firstCell.includes("没有数据")) hasData = true;
}
if(!hasData) {
Main.run(true, 'orders');
}
} catch(e) { console.error("[Monitor] Error:", e); }
}
};
setTimeout(AutoMonitor.start, 5000);
// ==========================================
// 6. UI 界面
// ==========================================
GM_addStyle(`
#ab-p-box { position: fixed; bottom: 20px; right: 20px; width: 320px; background: #fff; border: 1px solid #999; box-shadow: 0 0 15px rgba(0,0,0,0.5); z-index: 2147483647; display: none; padding: 10px; font-size:12px; font-family:sans-serif; border-radius:5px;}
#ab-p-bar { height: 8px; background: #eee; margin: 10px 0; border-radius:4px; overflow:hidden; }
#ab-p-in { height: 100%; width: 0%; background: #28a745; transition: width 0.3s; }
.ab-btn { padding: 5px 10px; cursor: pointer; border: 1px solid #ccc; background: #f8f8f8; margin-right: 5px; border-radius:3px; }
.ab-mask { position: fixed; top:0; left:0; width:100%; height:100%; background: #f0f2f5; z-index: 2147483647; display: flex; flex-direction: column; font-family: sans-serif; }
.ab-head { background: #fff; padding: 10px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; box-shadow:0 2px 5px rgba(0,0,0,0.05); }
.ab-body { flex: 1; display: flex; overflow: hidden; padding: 15px; gap: 15px; }
.ab-side { width: 260px; background: #fff; border: 1px solid #ddd; padding: 15px; overflow-y: auto; border-radius:5px; }
.ab-main { flex: 1; background: #fff; border: 1px solid #ddd; padding: 0; display: flex; flex-direction: column; border-radius:5px; overflow:hidden; }
.ab-row { margin-bottom: 15px; }
.ab-tag { display: inline-block; padding: 3px 8px; border: 1px solid #d9d9d9; margin: 3px; cursor: pointer; font-size: 12px; border-radius:2px; background:#fafafa; }
.ab-tag.on { background: #e6f7ff; border-color: #1890ff; color: #1890ff; }
.ab-tag.off { background: #fff1f0; border-color: #ff4d4f; color: #ff4d4f; text-decoration: line-through; }
table.ab-t { width: 100%; border-collapse: collapse; font-size: 12px; }
table.ab-t th, table.ab-t td { border-bottom: 1px solid #f0f0f0; padding: 8px 10px; text-align: left; }
table.ab-t th { background: #fafafa; cursor: pointer; position:sticky; top:0; z-index:1; border-bottom:2px solid #eee; }
table.ab-t tr:hover { background: #e6f7ff; }
.ab-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 5px 25px rgba(0,0,0,0.2); width: 500px; z-index: 2147483648; }
.ab-backdrop { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 2147483647; }
input[type=text], input[type=date] { padding:5px; border:1px solid #ccc; border-radius:3px; width:100%; box-sizing:border-box; margin-top:5px; }
`);
const UI = {
orders: [], reports: [], currentView: 'orders',
filters: { start: "", end: "", kw: "", tags: { techs:{}, phones:{}, projects:{}, statuses:{}, dian:{}, review:{} } },
sortStack: [{key:'createTimeRaw', dir:-1}],
lastReport: "", lastData: null,
openScraper: () => {
let box = document.getElementById('ab-p-box');
if(!box) {
box = document.createElement('div');
box.id = 'ab-p-box';
box.innerHTML = `
<div style="font-weight:bold;margin-bottom:5px;display:flex;justify-content:space-between"><span>数据抓取中...</span><span id="ab-mini-set" style="cursor:pointer">⚙️</span></div>
<div id="ab-p-bar"><div id="ab-p-in"></div></div>
<div id="ab-p-msg" style="color:#666">初始化...</div>
<div id="ab-p-btn" style="display:none;margin-top:10px;text-align:right">
<button class="ab-btn" id="ab-copy-j">复制JSON</button>
<button class="ab-btn" id="ab-copy-r" style="background:#1890ff;color:white;border:none">复制报表</button>
<button class="ab-btn" onclick="this.parentElement.parentElement.style.display='none'">关闭</button>
</div>`;
document.body.appendChild(box);
document.getElementById('ab-copy-r').onclick = () => { GM_setClipboard(UI.lastReport); alert("已复制"); };
document.getElementById('ab-copy-j').onclick = () => { GM_setClipboard(JSON.stringify(UI.lastData)); alert("已复制"); };
document.getElementById('ab-mini-set').onclick = UI.showConfig;
}
box.style.display = 'block';
document.getElementById('ab-p-btn').style.display = 'none';
UI.progress(0, "开始...");
},
progress: (pct, msg) => {
const bar = document.getElementById('ab-p-in');
const txt = document.getElementById('ab-p-msg');
if(bar && pct!=null) bar.style.width = pct + "%";
if(txt) txt.innerText = msg;
},
finish: (report, data) => {
UI.lastReport = report;
UI.lastData = data;
UI.progress(100, "完成!");
GM_setClipboard(report);
document.getElementById('ab-p-btn').style.display = 'block';
const d = document.createElement('div');
d.style.cssText = "position:fixed;top:10px;right:10px;background:green;color:white;padding:10px;z-index:9999999;border-radius:5px;";
d.innerText = "抓取完成,报表已复制";
document.body.appendChild(d);
setTimeout(() => d.remove(), 3000);
},
openQuery: async () => {
try {
Main.run(true, 'orders');
await DB.init();
const all = await DB.getAllOrders();
UI.orders = all;
UI.reports = await DB.getAllReports();
const range = Utils.getCurrentMonthRange();
UI.filters.start = range.start;
UI.filters.end = range.end;
UI.renderQuery();
UI.applyFilter();
document.onkeydown = (e) => { if(e.key === 'Escape') { const mask = document.querySelector('.ab-mask'); if(mask) mask.remove(); } };
} catch(e) { alert("打开失败: " + e); }
},
renderQuery: () => {
const old = document.querySelector('.ab-mask');
if(old) old.remove();
const div = document.createElement('div');
div.className = 'ab-mask';
div.innerHTML = `
<div class="ab-head">
<div style="font-size:18px;font-weight:bold">
<button class="ab-btn" id="ab-view-orders" style="background:#1890ff;color:white">订单查询</button>
<button class="ab-btn" id="ab-view-reports">日报历史</button>
</div>
<div><button class="ab-btn" id="ab-conf">⚙️ 配置</button><button class="ab-btn" id="ab-refresh">�� 抓取最新</button><button class="ab-btn" onclick="document.querySelector('.ab-mask').remove()" style="background:#ff4d4f;color:white;border:none">关闭(ESC)</button></div>
</div>
<div class="ab-body">
<div class="ab-side" id="ab-side-panel">
<div class="ab-row"><b>�� 日期</b><br><input type="date" id="d1" value="${UI.filters.start}"> 至 <input type="date" id="d2" value="${UI.filters.end}"></div>
<div class="ab-row"><b>�� 搜索</b><br><input type="text" id="kw" placeholder="技师/手机/订单号..."></div>
<div class="ab-row"><b>��️ 筛选(右键反选)</b><div id="ab-tags" style="margin-top:5px"></div></div>
<button class="ab-btn" id="ab-clear" style="width:100%">重置</button>
<div style="margin-top:20px;font-size:13px;line-height:1.6;color:#666">
总记录: <span id="ab-total" style="font-weight:bold;color:#333">0</span><br>
筛选后: <span id="ab-show" style="font-weight:bold;color:#1890ff">0</span><br>
金额: <span id="ab-amt" style="font-weight:bold;color:#f5222d">0</span>
</div>
</div>
<div class="ab-main">
<div style="flex:1;overflow:auto" id="ab-table-container"></div>
</div>
</div>`;
document.body.appendChild(div);
const btnOrders = document.getElementById('ab-view-orders');
const btnReports = document.getElementById('ab-view-reports');
const switchView = (view) => {
UI.currentView = view;
if (view === 'orders') {
btnOrders.style.cssText = "background:#1890ff;color:white";
btnReports.style.cssText = "";
document.getElementById('ab-side-panel').style.display = 'block';
UI.renderOrderTable();
} else {
btnReports.style.cssText = "background:#1890ff;color:white";
btnOrders.style.cssText = "";
document.getElementById('ab-side-panel').style.display = 'none';
UI.renderReportTable();
}
};
btnOrders.onclick = () => switchView('orders');
btnReports.onclick = () => switchView('reports');
document.getElementById('d1').onchange = (e) => { UI.filters.start = e.target.value; UI.currentView==='orders'?UI.applyFilter():UI.renderReportList(); };
document.getElementById('d2').onchange = (e) => { UI.filters.end = e.target.value; UI.currentView==='orders'?UI.applyFilter():UI.renderReportList(); };
document.getElementById('kw').oninput = (e) => { UI.filters.kw = e.target.value; UI.applyFilter(); };
document.getElementById('ab-clear').onclick = () => {
UI.filters.tags = { techs:{}, phones:{}, projects:{}, statuses:{}, dian:{}, review:{} };
UI.sortStack = [{key:'createTimeRaw', dir:-1}];
UI.applyFilter();
};
document.getElementById('ab-refresh').onclick = () => {
const btn = document.getElementById('ab-refresh');
btn.innerText = "⏳ 更新中...";
btn.disabled = true;
Main.run(true, 'orders').then(() => {
btn.innerText = "�� 抓取最新";
btn.disabled = false;
});
};
document.getElementById('ab-conf').onclick = UI.showConfig;
UI.renderOrderTable();
},
renderOrderTable: () => {
const container = document.getElementById('ab-table-container');
// 【UI补全】增加手机号列
container.innerHTML = `<table class="ab-t"><thead><tr><th k="createTimeRaw">时间</th><th k="orderId">订单号</th><th k="techName">技师</th><th k="isDian">点钟</th><th k="itemName">项目</th><th k="amount">金额</th><th k="payStatus">状态</th><th k="reviewStatus">评价</th><th k="phone">手机</th><th k="remark">备注</th></tr></thead><tbody id="ab-tbody"></tbody></table>`;
container.querySelectorAll('th').forEach(th => th.onclick = () => {
const k = th.getAttribute('k');
const idx = UI.sortStack.findIndex(s => s.key === k);
let newDir = -1;
if (idx !== -1) {
newDir = UI.sortStack[idx].dir * -1;
UI.sortStack.splice(idx, 1);
}
UI.sortStack.push({ key: k, dir: newDir });
UI.renderList();
});
UI.applyFilter();
},
renderReportTable: () => {
const container = document.getElementById('ab-table-container');
UI.reports.sort((a,b) => b.date.localeCompare(a.date));
let rows = UI.reports.map(r => {
const text = Core.formatReportText({data:r, date:r.date});
const preview = text.replace(/\n/g, ' ').substring(0, 50) + "...";
return `<tr><td>${r.date}</td><td title="${text}">${preview}</td><td><button class="ab-btn" onclick="copyRep('${encodeURIComponent(text)}')">复制</button></td></tr>`;
}).join('');
container.innerHTML = `<table class="ab-t"><thead><tr><th>日期</th><th>预览</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table>`;
window.copyRep = (enc) => { GM_setClipboard(decodeURIComponent(enc)); alert("日报已复制"); };
},
applyFilter: () => {
const f = UI.filters;
const start = f.start + " 06:00:00";
const d = new Date(f.end); d.setDate(d.getDate()+1);
const end = d.toISOString().split('T')[0] + " 06:00:00";
const kw = f.kw.toLowerCase();
const check = (val, map) => {
const keys = Object.keys(map);
if(!keys.length) return true;
const inc = keys.filter(k=>map[k]===1);
const exc = keys.filter(k=>map[k]===-1);
if(exc.includes(val)) return false;
if(inc.length && !inc.includes(val)) return false;
return true;
};
UI.viewData = UI.orders.filter(o => {
if(o.createTimeRaw < start || o.createTimeRaw > end) return false;
if(kw && !JSON.stringify(o).toLowerCase().includes(kw)) return false;
return check(o.techName, f.tags.techs) &&
check(o.itemName, f.tags.projects) &&
check(o.payStatus, f.tags.statuses) &&
check(o.isDian, f.tags.dian) &&
check(o.reviewStatus, f.tags.review);
});
document.getElementById('ab-total').innerText = UI.orders.length;
document.getElementById('ab-show').innerText = UI.viewData.length;
document.getElementById('ab-amt').innerText = UI.viewData.reduce((a,b)=>a+(b.amount||0),0).toFixed(2);
UI.renderTags();
UI.renderList();
},
renderTags: () => {
const div = document.getElementById('ab-tags');
div.innerHTML = "";
const addGroup = (map, label) => {
Object.keys(map).forEach(k => {
const s = map[k];
const sp = document.createElement('span');
sp.className = `ab-tag ${s===1?'on':'off'}`;
sp.innerText = `${label}:${k}`;
sp.onclick = () => { if(s===1) map[k]=-1; else delete map[k]; UI.applyFilter(); };
sp.oncontextmenu = (e) => { e.preventDefault(); if(s===1) map[k]=-1; else map[k]=1; UI.applyFilter(); };
div.appendChild(sp);
});
};
addGroup(UI.filters.tags.techs, '技师');
addGroup(UI.filters.tags.dian, '点钟');
addGroup(UI.filters.tags.projects, '项目');
addGroup(UI.filters.tags.statuses, '状态');
addGroup(UI.filters.tags.review, '评价');
},
renderList: () => {
const tb = document.getElementById('ab-tbody');
if(!tb) return;
tb.innerHTML = "";
UI.viewData.sort((a,b) => {
for (let s of UI.sortStack) {
const va = a[s.key]||"", vb = b[s.key]||"";
if(va === vb) continue;
if (typeof va === 'number' && typeof vb === 'number') return s.dir * (va - vb);
return s.dir * String(va).localeCompare(String(vb));
}
return 0;
});
UI.viewData.slice(0, 300).forEach(o => {
const tr = document.createElement('tr');
const td = (t, clickType) => {
const el = document.createElement('td');
el.innerText = t || '-';
if(clickType) {
el.style.color = "#1890ff"; el.style.cursor = "pointer";
el.onclick = (e) => { e.stopPropagation(); UI.filters.tags[clickType][t] = 1; UI.applyFilter(); };
}
return el;
};
tr.appendChild(td(o.createTimeRaw));
const idTd = document.createElement('td');
idTd.innerHTML = `<a href="${o.url}" target="_blank" style="color:blue;text-decoration:underline">${o.orderId}</a>`;
tr.appendChild(idTd);
tr.appendChild(td(o.techName, 'techs'));
tr.appendChild(td(o.isDian, 'dian'));
tr.appendChild(td(o.itemName, 'projects'));
tr.appendChild(td(o.amount));
tr.appendChild(td(o.payStatus, 'statuses'));
tr.appendChild(td(o.reviewStatus, 'review'));
tr.appendChild(td(o.phone)); // 【UI补全】渲染手机号列
const remTd = document.createElement('td');
remTd.innerText = o.remark || '...';
remTd.style.cursor = 'pointer';
remTd.onclick = () => UI.editRemark(o);
tr.appendChild(remTd);
tb.appendChild(tr);
});
},
editRemark: (o) => {
const txt = prompt("编辑备注:", o.remark || "");
if(txt !== null) {
DB.updateRemark(o.orderId, txt).then(() => {
o.remark = txt;
UI.renderList();
});
}
},
showConfig: () => {
const list = ConfigManager.getTechList();
const shopId = ConfigManager.getShopId();
const back = document.createElement('div'); back.className = 'ab-backdrop';
const box = document.createElement('div'); box.className = 'ab-modal';
box.innerHTML = `<h3>⚙️ 设置</h3>
<div style="margin-bottom:10px"><b>店铺ID</b> (默认12)<br><input type="text" id="ab-shop-id" value="${shopId}" style="width:100%"></div>
<div><b>技师白名单</b><br><textarea id="ab-conf-area" style="width:100%;height:150px;margin-top:5px">${list.join('\n')}</textarea></div>
<div style="text-align:right;margin-top:10px">
<button class="ab-btn" id="ab-conf-save" style="background:#28a745;color:white;border:none">保存设置</button>
<button class="ab-btn" onclick="this.closest('.ab-backdrop').remove()">取消</button>
</div>
<div style="margin-top:20px;border-top:1px solid #eee;padding-top:10px">
<button class="ab-btn" id="ab-db-reset" style="background:red;color:white;width:100%">⚠️ 重置数据库</button>
</div>`;
back.appendChild(box); document.body.appendChild(back);
document.getElementById('ab-conf-save').onclick = () => {
const txt = document.getElementById('ab-conf-area').value;
const sid = document.getElementById('ab-shop-id').value.trim();
const arr = txt.split('\n').map(t=>t.trim()).filter(t=>t);
ConfigManager.saveTechList(arr);
ConfigManager.saveShopId(sid);
alert("设置已保存");
back.remove();
};
document.getElementById('ab-db-reset').onclick = async () => {
if(confirm("确定清空所有数据?")) {
await DB.reset();
alert("已重置,请刷新页面");
location.reload();
}
};
}
};
const Main = {
run: async (isSilent, target='all') => {
try {
if(!isSilent) UI.openScraper();
else UI.progress(0, "后台静默抓取...");
await DB.init();
let tasks = TaskManager.getTasks();
if(target === 'orders') tasks = tasks.filter(t => t.id === 'orders');
const finalData = {};
for(let i=0; i<tasks.length; i++) {
const t = tasks[i];
if(!isSilent) UI.progress((i/tasks.length)*80, "抓取: " + t.title);
try {
const html = await Network.get(t.url);
const res = t.parser(html);
if(res.data) {
if(t.id === 'orders') {
const c = await DB.saveOrders(res.data);
if(!isSilent) UI.progress(null, `入库 ${c} 条`);
if(isSilent) {
UI.orders = await DB.getAllOrders();
if(UI.currentView === 'orders') UI.applyFilter();
const d = document.createElement('div');
d.style.cssText = "position:fixed;top:10px;right:10px;background:green;color:white;padding:10px;z-index:9999999;border-radius:5px;";
d.innerText = `后台更新完成:${c} 条数据`;
document.body.appendChild(d);
setTimeout(() => d.remove(), 3000);
}
}
finalData[t.title] = res.data; // 修复点:直接赋值data数组
}
} catch(e) { console.error(e); }
await new Promise(r=>setTimeout(r, 600));
}
if(isSilent) return;
if(finalData['营业统计']) {
const reportData = Core.generateReportData(finalData);
await DB.saveReportData(reportData);
const reportText = Core.formatReportText({data:reportData, date:reportData.date});
UI.finish(reportText, finalData);
} else if(target !== 'all') {
UI.progress(100, "订单更新完成");
setTimeout(() => {
document.getElementById('ab-p-box').style.display = 'none';
toast("数据已同步");
}, 1000);
}
} catch(e) { alert("运行异常: " + e); }
}
};
GM_registerMenuCommand("�� 开始抓取", () => Main.run(false, 'all'));
GM_registerMenuCommand("�� 打开数据查询面板", async () => { try { await DB.init(); UI.openQuery(); } catch(e) { alert("打开面板失败:" + e); } });
})();
console