|
@@ -28,6 +28,8 @@
|
|
|
shops: null, // null = 全部店铺
|
|
shops: null, // null = 全部店铺
|
|
|
fields: loadJSON('ops.fields', DEFAULT_FIELDS.slice()),
|
|
fields: loadJSON('ops.fields', DEFAULT_FIELDS.slice()),
|
|
|
view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
|
|
view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
|
|
|
|
|
+ sortKey: null,
|
|
|
|
|
+ sortDir: 'desc',
|
|
|
from: '',
|
|
from: '',
|
|
|
to: '',
|
|
to: '',
|
|
|
upload: { files: [], busy: false },
|
|
upload: { files: [], busy: false },
|
|
@@ -308,7 +310,7 @@
|
|
|
}
|
|
}
|
|
|
if (!state.data) return;
|
|
if (!state.data) return;
|
|
|
body.innerHTML = state.view === 'cards' ? renderCards(state.data) : renderTables(state.data);
|
|
body.innerHTML = state.view === 'cards' ? renderCards(state.data) : renderTables(state.data);
|
|
|
- if (state.view === 'table') bindRowHover();
|
|
|
|
|
|
|
+ if (state.view === 'table') { bindRowHover(); bindSort(); }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function cellValue(shop, field, date) {
|
|
function cellValue(shop, field, date) {
|
|
@@ -364,6 +366,53 @@
|
|
|
return state.fields.map((key) => fieldByKey(key));
|
|
return state.fields.map((key) => fieldByKey(key));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 门店在某字段上的区间汇总:金额/数量求和,比率按曝光加权 */
|
|
|
|
|
+ function fieldAggregate(shop, field, dates) {
|
|
|
|
|
+ if (field.type === 'rate') {
|
|
|
|
|
+ let num = 0;
|
|
|
|
|
+ let den = 0;
|
|
|
|
|
+ for (const d of dates) {
|
|
|
|
|
+ const v = cellValue(shop, field, d);
|
|
|
|
|
+ if (v == null) continue;
|
|
|
|
|
+ const weight = Number((shop.series && shop.series.exposure && shop.series.exposure[d]) || 1) || 1;
|
|
|
|
|
+ num += v * weight;
|
|
|
|
|
+ den += weight;
|
|
|
|
|
+ }
|
|
|
|
|
+ return den ? num / den : null;
|
|
|
|
|
+ }
|
|
|
|
|
+ let sum = 0;
|
|
|
|
|
+ let has = false;
|
|
|
|
|
+ for (const d of dates) {
|
|
|
|
|
+ const v = cellValue(shop, field, d);
|
|
|
|
|
+ if (typeof v === 'number') { sum += v; has = true; }
|
|
|
|
|
+ }
|
|
|
|
|
+ return has ? sum : null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 按当前排序列排序:门店在各自学校内排序,学校按小计排序 */
|
|
|
|
|
+ function sortGroups(groups, fields, dates) {
|
|
|
|
|
+ if (!state.sortKey) return groups;
|
|
|
|
|
+ const field = fields.find((f) => f.key === state.sortKey);
|
|
|
|
|
+ if (!field || field.type === 'text') return groups;
|
|
|
|
|
+ const dir = state.sortDir === 'asc' ? 1 : -1;
|
|
|
|
|
+ const cmp = (a, b) => {
|
|
|
|
|
+ if (a == null && b == null) return 0;
|
|
|
|
|
+ if (a == null) return 1;
|
|
|
|
|
+ if (b == null) return -1;
|
|
|
|
|
+ return dir * (a - b);
|
|
|
|
|
+ };
|
|
|
|
|
+ const groupTotal = (group) => group.shops.reduce((acc, shop) => {
|
|
|
|
|
+ const v = fieldAggregate(shop, field, dates);
|
|
|
|
|
+ return v == null ? acc : acc + v;
|
|
|
|
|
+ }, 0);
|
|
|
|
|
+ return groups
|
|
|
|
|
+ .map((group) => ({
|
|
|
|
|
+ ...group,
|
|
|
|
|
+ shops: group.shops.slice().sort((a, b) => cmp(fieldAggregate(a, field, dates), fieldAggregate(b, field, dates))),
|
|
|
|
|
+ }))
|
|
|
|
|
+ .sort((a, b) => cmp(groupTotal(a), groupTotal(b)));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
function renderTables(data) {
|
|
function renderTables(data) {
|
|
|
const fields = activeFields(data);
|
|
const fields = activeFields(data);
|
|
|
if (!fields.length) return '<div class="empty-hint"><p>请至少选择一个字段</p></div>';
|
|
if (!fields.length) return '<div class="empty-hint"><p>请至少选择一个字段</p></div>';
|
|
@@ -373,10 +422,18 @@
|
|
|
const head = `<tr>
|
|
const head = `<tr>
|
|
|
<th class="ops-shop-col">店铺</th>
|
|
<th class="ops-shop-col">店铺</th>
|
|
|
<th class="ops-date-col">日期</th>
|
|
<th class="ops-date-col">日期</th>
|
|
|
- ${fields.map((f) => `<th class="ops-metric-col ${f.type === 'text' ? 'ops-col-text' : 'ops-col-num'}" title="${esc(f.label)}">${esc(f.label)}</th>`).join('')}
|
|
|
|
|
|
|
+ ${fields.map((f) => {
|
|
|
|
|
+ const sortable = f.type !== 'text';
|
|
|
|
|
+ const active = state.sortKey === f.key;
|
|
|
|
|
+ const arrow = active ? (state.sortDir === 'asc' ? '↑' : '↓') : '';
|
|
|
|
|
+ return `<th class="ops-metric-col ${f.type === 'text' ? 'ops-col-text' : 'ops-col-num'}${sortable ? ' ops-sortable' : ''}${active ? ' ops-sorted' : ''}"${sortable ? ` data-sort="${esc(f.key)}"` : ''} title="${esc(f.label)}${sortable ? '(点击排序)' : ''}">${esc(f.label)}${arrow ? `<span class="ops-sort-arrow">${arrow}</span>` : ''}</th>`;
|
|
|
|
|
+ }).join('')}
|
|
|
</tr>`;
|
|
</tr>`;
|
|
|
|
|
|
|
|
- const body = data.groups.map((group, groupIdx) => {
|
|
|
|
|
|
|
+
|
|
|
|
|
+ const groups = sortGroups(data.groups, fields, dates);
|
|
|
|
|
+
|
|
|
|
|
+ const body = groups.map((group, groupIdx) => {
|
|
|
const schoolRow = `<tr class="ops-school-row"><td colspan="${colCount}">${esc(group.school)}<em>${group.shops.length} 家</em></td></tr>`;
|
|
const schoolRow = `<tr class="ops-school-row"><td colspan="${colCount}">${esc(group.school)}<em>${group.shops.length} 家</em></td></tr>`;
|
|
|
|
|
|
|
|
const shopRows = group.shops.map((shop, shopIdx) => {
|
|
const shopRows = group.shops.map((shop, shopIdx) => {
|
|
@@ -405,7 +462,7 @@
|
|
|
return schoolRow + shopRows + subtotalRows;
|
|
return schoolRow + shopRows + subtotalRows;
|
|
|
}).join('');
|
|
}).join('');
|
|
|
|
|
|
|
|
- const shopCount = data.groups.reduce((n, g) => n + g.shops.length, 0);
|
|
|
|
|
|
|
+ const shopCount = groups.reduce((n, g) => n + g.shops.length, 0);
|
|
|
return `<section class="ops-block">
|
|
return `<section class="ops-block">
|
|
|
<div class="ops-block-head"><h2>运营明细</h2><span>${shopCount} 家门店 · ${dates.length} 天 · ${fields.length} 个字段</span></div>
|
|
<div class="ops-block-head"><h2>运营明细</h2><span>${shopCount} 家门店 · ${dates.length} 天 · ${fields.length} 个字段</span></div>
|
|
|
<div class="ops-table-wrap">
|
|
<div class="ops-table-wrap">
|
|
@@ -418,6 +475,19 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 悬停时整块高亮(同一店铺的多天一起亮),避免分不清行属于哪家店 */
|
|
/** 悬停时整块高亮(同一店铺的多天一起亮),避免分不清行属于哪家店 */
|
|
|
|
|
+ function bindSort() {
|
|
|
|
|
+ document.querySelectorAll('.ops-table-wide th[data-sort]').forEach((th) => {
|
|
|
|
|
+ if (th.dataset.sortBound === '1') return;
|
|
|
|
|
+ th.dataset.sortBound = '1';
|
|
|
|
|
+ th.addEventListener('click', () => {
|
|
|
|
|
+ const key = th.dataset.sort;
|
|
|
|
|
+ if (state.sortKey === key) state.sortDir = state.sortDir === 'desc' ? 'asc' : 'desc';
|
|
|
|
|
+ else { state.sortKey = key; state.sortDir = 'desc'; }
|
|
|
|
|
+ renderBody();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
function bindRowHover() {
|
|
function bindRowHover() {
|
|
|
document.querySelectorAll('.ops-table-wide tbody').forEach((tbody) => {
|
|
document.querySelectorAll('.ops-table-wide tbody').forEach((tbody) => {
|
|
|
if (tbody.dataset.hoverBound === '1') return;
|
|
if (tbody.dataset.hoverBound === '1') return;
|