|
|
@@ -5,21 +5,14 @@
|
|
|
(function () {
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
|
|
- const META = {
|
|
|
- order_cnt: { label: '订单量', type: 'int' },
|
|
|
- revenue: { label: '营收', type: 'money' },
|
|
|
- exposure: { label: '曝光量', type: 'int' },
|
|
|
- click_rate: { label: '进店转化率', type: 'rate' },
|
|
|
- };
|
|
|
- const ALL_METRICS = Object.keys(META);
|
|
|
- const DEFAULT_METRICS = ALL_METRICS.slice();
|
|
|
+ const DEFAULT_FIELDS = ['order_cnt', 'revenue', 'exposure', 'click_rate'];
|
|
|
|
|
|
function loadJSON(key, fallback) {
|
|
|
try {
|
|
|
const raw = localStorage.getItem(key);
|
|
|
if (!raw) return fallback;
|
|
|
- const v = JSON.parse(raw);
|
|
|
- return Array.isArray(v) && v.length ? v.filter((x) => ALL_METRICS.includes(x)) : fallback;
|
|
|
+ const value = JSON.parse(raw);
|
|
|
+ return Array.isArray(value) && value.length ? value : fallback;
|
|
|
} catch {
|
|
|
return fallback;
|
|
|
}
|
|
|
@@ -30,14 +23,20 @@
|
|
|
loading: false,
|
|
|
summary: null,
|
|
|
data: null,
|
|
|
+ catalog: [],
|
|
|
schools: null, // null = 全选
|
|
|
- metrics: loadJSON('ops.metrics', DEFAULT_METRICS),
|
|
|
+ shops: null, // null = 全部店铺
|
|
|
+ fields: loadJSON('ops.fields', DEFAULT_FIELDS.slice()),
|
|
|
view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
|
|
|
from: '',
|
|
|
to: '',
|
|
|
upload: { files: [], busy: false },
|
|
|
};
|
|
|
|
|
|
+ function fieldByKey(key) {
|
|
|
+ return state.catalog.find((f) => f.key === key) || { key, label: key, type: 'int' };
|
|
|
+ }
|
|
|
+
|
|
|
function esc(value) {
|
|
|
const div = document.createElement('div');
|
|
|
div.textContent = String(value == null ? '' : value);
|
|
|
@@ -58,13 +57,15 @@
|
|
|
return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}.${Number(s.slice(6, 8))}` : s;
|
|
|
}
|
|
|
|
|
|
- function fmtValue(value, metric) {
|
|
|
- if (value === null || value === undefined) return '–';
|
|
|
+ function fmtValue(value, type) {
|
|
|
+ if (value === null || value === undefined || value === '') return '–';
|
|
|
+ if (type === 'text') return String(value);
|
|
|
const n = Number(value);
|
|
|
- if (!Number.isFinite(n)) return '–';
|
|
|
- if (META[metric].type === 'rate') return (n * 100).toFixed(1) + '%';
|
|
|
- if (META[metric].type === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
|
- return n.toLocaleString('zh-CN');
|
|
|
+ if (!Number.isFinite(n)) return String(value);
|
|
|
+ if (type === 'rate') return (n * 100).toFixed(1) + '%';
|
|
|
+ if (type === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
|
+ if (type === 'int') return n.toLocaleString('zh-CN');
|
|
|
+ return String(value);
|
|
|
}
|
|
|
|
|
|
function selectedSchools() {
|
|
|
@@ -107,7 +108,8 @@
|
|
|
if (state.to) params.set('to', state.to);
|
|
|
const schools = state.schools === null ? [] : [...state.schools];
|
|
|
if (schools.length && schools.length !== (state.summary.schools || []).length) params.set('schools', schools.join(','));
|
|
|
- params.set('metrics', state.metrics.join(','));
|
|
|
+ if (state.shops !== null && state.shops.size) params.set('shops', [...state.shops].join(','));
|
|
|
+ if (state.fields.length) params.set('fields', state.fields.join(','));
|
|
|
const { res, body } = await api('/api/ops/rows?' + params.toString());
|
|
|
state.loading = false;
|
|
|
if (!res.ok) {
|
|
|
@@ -122,12 +124,88 @@
|
|
|
|
|
|
// ---------- 工具条 ----------
|
|
|
|
|
|
+ /** 通用多选下拉:按钮 + 搜索 + 复选列表 + 全选/清空 */
|
|
|
+ function mountMulti(root, { label, items, selected, allowEmpty, emptyLabel, onChange }) {
|
|
|
+ const sel = new Set(selected || []);
|
|
|
+ let keyword = '';
|
|
|
+ root.innerHTML = `<button type="button" class="ops-multi-btn" aria-expanded="false"></button>
|
|
|
+ <div class="ops-multi-menu" hidden>
|
|
|
+ <input type="text" class="ops-multi-search" placeholder="搜索${esc(label)}">
|
|
|
+ <div class="ops-multi-list"></div>
|
|
|
+ <div class="ops-multi-foot"><button type="button" data-all>全选</button><button type="button" data-none>清空</button></div>
|
|
|
+ </div>`;
|
|
|
+ const btn = root.querySelector('.ops-multi-btn');
|
|
|
+ const menu = root.querySelector('.ops-multi-menu');
|
|
|
+ const list = root.querySelector('.ops-multi-list');
|
|
|
+ const search = root.querySelector('.ops-multi-search');
|
|
|
+
|
|
|
+ function refreshButton() {
|
|
|
+ let text;
|
|
|
+ if (!sel.size) text = `${label} <em>${emptyLabel || '全部'}</em>`;
|
|
|
+ else if (sel.size === 1) {
|
|
|
+ const one = items.find((i) => sel.has(i.key));
|
|
|
+ text = `${label} <em>${esc(one ? one.label : '1')}</em>`;
|
|
|
+ } else text = `${label} <em>${sel.size} 项</em>`;
|
|
|
+ btn.innerHTML = text + '<span class="ops-multi-caret">▾</span>';
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderList() {
|
|
|
+ const kw = keyword.trim().toLowerCase();
|
|
|
+ const filtered = items.filter((i) => !kw
|
|
|
+ || String(i.label).toLowerCase().includes(kw)
|
|
|
+ || String(i.hint || '').toLowerCase().includes(kw));
|
|
|
+ list.innerHTML = filtered.length
|
|
|
+ ? filtered.map((i) => `<label class="ops-multi-item">
|
|
|
+ <input type="checkbox" value="${esc(i.key)}"${sel.has(i.key) ? ' checked' : ''}>
|
|
|
+ <span>${esc(i.label)}</span>${i.hint ? `<em>${esc(i.hint)}</em>` : ''}
|
|
|
+ </label>`).join('')
|
|
|
+ : '<div class="ops-multi-empty">没有匹配项</div>';
|
|
|
+ list.querySelectorAll('input[type="checkbox"]').forEach((input) => {
|
|
|
+ input.addEventListener('change', () => {
|
|
|
+ if (input.checked) sel.add(input.value);
|
|
|
+ else sel.delete(input.value);
|
|
|
+ if (!allowEmpty && !sel.size) {
|
|
|
+ input.checked = true;
|
|
|
+ sel.add(input.value);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ refreshButton();
|
|
|
+ onChange([...sel]);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ btn.addEventListener('click', (e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ const willOpen = menu.hidden;
|
|
|
+ document.querySelectorAll('.ops-multi-menu').forEach((m) => { m.hidden = true; });
|
|
|
+ menu.hidden = !willOpen;
|
|
|
+ btn.setAttribute('aria-expanded', String(willOpen));
|
|
|
+ if (willOpen) { renderList(); search.focus(); }
|
|
|
+ });
|
|
|
+ search.addEventListener('input', () => { keyword = search.value; renderList(); });
|
|
|
+ root.querySelector('[data-all]').addEventListener('click', () => {
|
|
|
+ items.forEach((i) => sel.add(i.key));
|
|
|
+ refreshButton();
|
|
|
+ renderList();
|
|
|
+ onChange([...sel]);
|
|
|
+ });
|
|
|
+ root.querySelector('[data-none]').addEventListener('click', () => {
|
|
|
+ sel.clear();
|
|
|
+ if (!allowEmpty && items[0]) sel.add(items[0].key);
|
|
|
+ refreshButton();
|
|
|
+ renderList();
|
|
|
+ onChange([...sel]);
|
|
|
+ });
|
|
|
+ refreshButton();
|
|
|
+ return { refresh: () => { refreshButton(); renderList(); } };
|
|
|
+ }
|
|
|
+
|
|
|
function renderBar() {
|
|
|
const bar = $('opsBar');
|
|
|
if (!bar) return;
|
|
|
const summary = state.summary;
|
|
|
- const matrixRows = matrixCount(summary);
|
|
|
- if (!summary || !summary.enabled || !matrixRows) {
|
|
|
+ if (!summary || !summary.enabled || !matrixCount(summary)) {
|
|
|
bar.innerHTML = '';
|
|
|
return;
|
|
|
}
|
|
|
@@ -148,10 +226,12 @@
|
|
|
<input type="date" class="ops-input" id="opsTo" value="${ymdToInput(state.to)}">
|
|
|
</div>
|
|
|
<div class="ops-bar-group">
|
|
|
- <span class="ops-bar-label">指标</span>
|
|
|
- <div class="ops-chips" id="opsMetricChips">
|
|
|
- ${ALL_METRICS.map((k) => `<button type="button" class="ops-chip${state.metrics.includes(k) ? ' on' : ''}" data-metric="${k}">${META[k].label}</button>`).join('')}
|
|
|
- </div>
|
|
|
+ <span class="ops-bar-label">字段</span>
|
|
|
+ <div class="ops-multi" id="opsFieldPicker"></div>
|
|
|
+ </div>
|
|
|
+ <div class="ops-bar-group">
|
|
|
+ <span class="ops-bar-label">店铺</span>
|
|
|
+ <div class="ops-multi" id="opsShopPicker"></div>
|
|
|
</div>
|
|
|
</div>`;
|
|
|
renderHeaderTools();
|
|
|
@@ -167,22 +247,37 @@
|
|
|
loadRows();
|
|
|
});
|
|
|
});
|
|
|
- bar.querySelectorAll('[data-metric]').forEach((btn) => {
|
|
|
- btn.addEventListener('click', () => {
|
|
|
- const key = btn.dataset.metric;
|
|
|
- if (state.metrics.includes(key)) {
|
|
|
- if (state.metrics.length === 1) return; // 至少保留一个指标
|
|
|
- state.metrics = state.metrics.filter((k) => k !== key);
|
|
|
- } else {
|
|
|
- state.metrics = ALL_METRICS.filter((k) => key === k || state.metrics.includes(k));
|
|
|
- }
|
|
|
- localStorage.setItem('ops.metrics', JSON.stringify(state.metrics));
|
|
|
- renderBar();
|
|
|
- renderBody();
|
|
|
- });
|
|
|
- });
|
|
|
$('opsFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadRows(); });
|
|
|
$('opsTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadRows(); });
|
|
|
+
|
|
|
+ mountMulti($('opsFieldPicker'), {
|
|
|
+ label: '字段',
|
|
|
+ items: state.catalog.map((f) => ({ key: f.key, label: f.label, hint: f.type === 'text' ? '文本' : '' })),
|
|
|
+ selected: state.fields,
|
|
|
+ allowEmpty: false,
|
|
|
+ onChange: (keys) => {
|
|
|
+ state.fields = keys;
|
|
|
+ localStorage.setItem('ops.fields', JSON.stringify(keys));
|
|
|
+ loadRows();
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ const shopItems = (summary.shops || []).map((shop) => ({
|
|
|
+ key: String(shop.key || shop.shopId || ''),
|
|
|
+ label: shop.shopName || shop.key,
|
|
|
+ hint: shop.school || '',
|
|
|
+ })).filter((i) => i.key);
|
|
|
+ mountMulti($('opsShopPicker'), {
|
|
|
+ label: '店铺',
|
|
|
+ items: shopItems,
|
|
|
+ selected: state.shops ? [...state.shops] : [],
|
|
|
+ allowEmpty: true,
|
|
|
+ emptyLabel: '全部',
|
|
|
+ onChange: (keys) => {
|
|
|
+ state.shops = keys.length ? new Set(keys) : null;
|
|
|
+ loadRows();
|
|
|
+ },
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
function matrixCount(summary) {
|
|
|
@@ -216,7 +311,7 @@
|
|
|
if (state.from) params.set('from', state.from);
|
|
|
if (state.to) params.set('to', state.to);
|
|
|
if (state.schools !== null && state.schools.size) params.set('schools', [...state.schools].join(','));
|
|
|
- params.set('metrics', state.metrics.join(','));
|
|
|
+ if (state.fields.length) params.set('fields', state.fields.join(','));
|
|
|
window.open('/api/ops/export?' + params.toString(), '_blank');
|
|
|
});
|
|
|
}
|
|
|
@@ -283,28 +378,35 @@
|
|
|
if (state.view === 'table') bindRowHover();
|
|
|
}
|
|
|
|
|
|
- function cellValue(shop, metric, date) {
|
|
|
- const series = shop.series[metric] || {};
|
|
|
+ function cellValue(shop, field, date) {
|
|
|
+ const series = shop.series[field.key] || {};
|
|
|
return Object.prototype.hasOwnProperty.call(series, date) ? series[date] : null;
|
|
|
}
|
|
|
|
|
|
- function metricCell(shop, metric, date) {
|
|
|
- const value = cellValue(shop, metric, date);
|
|
|
+ function metricCell(shop, field, date) {
|
|
|
+ const value = cellValue(shop, field, date);
|
|
|
const quality = (shop.quality || {})[date];
|
|
|
const warn = quality && quality.length
|
|
|
? `<span class="ops-warn" title="${esc('解析标记:' + quality.join('、'))}">⚠</span>`
|
|
|
: '';
|
|
|
- return `<td class="ops-num${value == null ? ' ops-null' : ''}">${fmtValue(value, metric)}${warn}</td>`;
|
|
|
+ return `<td class="ops-num${value == null ? ' ops-null' : ''}">${fmtValue(value, field.type)}${warn}</td>`;
|
|
|
}
|
|
|
|
|
|
- /** 学校小计:数量/金额求和,转化率按曝光加权 */
|
|
|
- function subtotalValue(group, metric, date) {
|
|
|
- if (metric === 'click_rate') {
|
|
|
+ /** 学校小计:数量/金额求和,比率按曝光加权,文本取第一个非空 */
|
|
|
+ function subtotalValue(group, field, date) {
|
|
|
+ if (field.type === 'text') {
|
|
|
+ for (const shop of group.shops) {
|
|
|
+ const v = cellValue(shop, field, date);
|
|
|
+ if (v !== null) return v;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (field.type === 'rate') {
|
|
|
let weight = 0;
|
|
|
let acc = 0;
|
|
|
for (const shop of group.shops) {
|
|
|
- const exposure = cellValue(shop, 'exposure', date);
|
|
|
- const rate = cellValue(shop, 'click_rate', date);
|
|
|
+ const exposure = cellValue(shop, { key: 'exposure' }, date);
|
|
|
+ const rate = cellValue(shop, field, date);
|
|
|
if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
|
|
|
weight += exposure;
|
|
|
acc += rate * exposure;
|
|
|
@@ -315,31 +417,35 @@
|
|
|
let sum = 0;
|
|
|
let seen = false;
|
|
|
for (const shop of group.shops) {
|
|
|
- const v = cellValue(shop, metric, date);
|
|
|
+ const v = cellValue(shop, field, date);
|
|
|
if (typeof v === 'number') { sum += v; seen = true; }
|
|
|
}
|
|
|
return seen ? sum : null;
|
|
|
}
|
|
|
|
|
|
+ function activeFields(data) {
|
|
|
+ if (data && Array.isArray(data.fields) && data.fields.length) return data.fields;
|
|
|
+ return state.fields.map((key) => fieldByKey(key));
|
|
|
+ }
|
|
|
+
|
|
|
function renderTables(data) {
|
|
|
- const metrics = state.metrics.filter((m) => META[m]);
|
|
|
- if (!metrics.length) return '<div class="empty-hint"><p>请至少选择一个指标</p></div>';
|
|
|
+ const fields = activeFields(data);
|
|
|
+ if (!fields.length) return '<div class="empty-hint"><p>请至少选择一个字段</p></div>';
|
|
|
const dates = data.dates || [];
|
|
|
- const colCount = metrics.length + 2;
|
|
|
+ const colCount = fields.length + 2;
|
|
|
|
|
|
const head = `<tr>
|
|
|
<th class="ops-shop-col">店铺</th>
|
|
|
<th class="ops-date-col">日期</th>
|
|
|
- ${metrics.map((m) => `<th class="ops-metric-col">${META[m].label}</th>`).join('')}
|
|
|
+ ${fields.map((f) => `<th class="ops-metric-col" title="${esc(f.label)}">${esc(f.label)}</th>`).join('')}
|
|
|
</tr>`;
|
|
|
|
|
|
const body = data.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 shopRows = group.shops.map((shop, shopIdx) => {
|
|
|
- const shopDates = dates.filter((d) => metrics.some((m) => cellValue(shop, m, d) !== null));
|
|
|
+ const shopDates = dates.filter((d) => fields.some((f) => cellValue(shop, f, d) !== null));
|
|
|
const useDates = shopDates.length ? shopDates : dates.slice(0, 1);
|
|
|
- // 每个店铺一个色块与悬停组,日期行共用同一个 block key
|
|
|
const blockKey = `b${groupIdx}-${shopIdx}`;
|
|
|
const alt = shopIdx % 2 === 1 ? ' ops-block-alt' : '';
|
|
|
return useDates.map((date, i) => `<tr data-block="${blockKey}" class="ops-shop-row${i === 0 ? ' ops-block-start' : ''}${alt}">
|
|
|
@@ -347,16 +453,16 @@
|
|
|
shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted ops-status">${esc(shop.status)}</span>` : ''
|
|
|
}</td>` : ''}
|
|
|
<td class="ops-date-cell">${shortDate(date)}</td>
|
|
|
- ${metrics.map((m) => metricCell(shop, m, date)).join('')}
|
|
|
+ ${fields.map((f) => metricCell(shop, f, date)).join('')}
|
|
|
</tr>`).join('');
|
|
|
}).join('');
|
|
|
|
|
|
- const subtotalDates = dates.filter((d) => group.shops.some((shop) => metrics.some((m) => cellValue(shop, m, d) !== null)));
|
|
|
+ const subtotalDates = dates.filter((d) => group.shops.some((shop) => fields.some((f) => cellValue(shop, f, d) !== null)));
|
|
|
const subtotalRows = subtotalDates.map((date) => `<tr class="ops-subtotal-row">
|
|
|
<td class="ops-subtotal-label" colspan="2">${esc(group.school)} · 小计 · ${shortDate(date)}</td>
|
|
|
- ${metrics.map((m) => {
|
|
|
- const v = subtotalValue(group, m, date);
|
|
|
- return `<td class="ops-num ops-subtotal">${v == null ? '–' : fmtValue(v, m)}</td>`;
|
|
|
+ ${fields.map((f) => {
|
|
|
+ const v = subtotalValue(group, f, date);
|
|
|
+ return `<td class="ops-num ops-subtotal">${v == null ? '–' : fmtValue(v, f.type)}</td>`;
|
|
|
}).join('')}
|
|
|
</tr>`).join('');
|
|
|
|
|
|
@@ -365,7 +471,7 @@
|
|
|
|
|
|
const shopCount = data.groups.reduce((n, g) => n + g.shops.length, 0);
|
|
|
return `<section class="ops-block">
|
|
|
- <div class="ops-block-head"><h2>运营明细</h2><span>${shopCount} 家门店 · ${dates.length} 天 · ${metrics.length} 个指标</span></div>
|
|
|
+ <div class="ops-block-head"><h2>运营明细</h2><span>${shopCount} 家门店 · ${dates.length} 天 · ${fields.length} 个字段</span></div>
|
|
|
<div class="ops-table-wrap">
|
|
|
<table class="ops-table ops-table-wide">
|
|
|
<thead>${head}</thead>
|
|
|
@@ -392,9 +498,9 @@
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- function sparkline(shop, metric, dates) {
|
|
|
+ function sparkline(shop, field, dates) {
|
|
|
const values = dates.map((d) => {
|
|
|
- const v = (shop.series[metric] || {})[d];
|
|
|
+ const v = (shop.series[field.key] || {})[d];
|
|
|
return typeof v === 'number' ? v : null;
|
|
|
});
|
|
|
const nums = values.filter((v) => v != null);
|
|
|
@@ -417,7 +523,9 @@
|
|
|
|
|
|
function renderCards(data) {
|
|
|
const dates = data.dates || [];
|
|
|
- const metric = state.metrics[0];
|
|
|
+ const fields = activeFields(data);
|
|
|
+ const first = fields[0];
|
|
|
+ if (!first) return '<div class="empty-hint"><p>请至少选择一个字段</p></div>';
|
|
|
return `<div class="ops-cards">${data.groups.map((group) => `
|
|
|
<section class="ops-card-group">
|
|
|
<h3>${esc(group.school)}</h3>
|
|
|
@@ -429,28 +537,14 @@
|
|
|
${shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted">${esc(shop.status)}</span>` : ''}
|
|
|
</div>
|
|
|
<div class="ops-card-metrics">
|
|
|
- ${state.metrics.map((m) => `<div class="ops-card-metric"><span>${META[m].label}</span><b>${m === 'click_rate' ? fmtValue(avgRate(shop, dates), m) : fmtValue(shop.totals && shop.totals[m], m)}</b></div>`).join('')}
|
|
|
+ ${fields.map((f) => `<div class="ops-card-metric"><span>${esc(f.label)}</span><b>${fmtValue(shop.totals ? shop.totals[f.key] : null, f.type)}</b></div>`).join('')}
|
|
|
</div>
|
|
|
- ${sparkline(shop, metric, dates)}
|
|
|
+ ${sparkline(shop, first, dates)}
|
|
|
</article>`).join('')}
|
|
|
</div>
|
|
|
</section>`).join('')}</div>`;
|
|
|
}
|
|
|
|
|
|
- function avgRate(shop, dates) {
|
|
|
- let weight = 0;
|
|
|
- let acc = 0;
|
|
|
- for (const date of dates) {
|
|
|
- const exposure = (shop.series.exposure || {})[date];
|
|
|
- const rate = (shop.series.click_rate || {})[date];
|
|
|
- if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
|
|
|
- weight += exposure;
|
|
|
- acc += rate * exposure;
|
|
|
- }
|
|
|
- }
|
|
|
- return weight > 0 ? acc / weight : null;
|
|
|
- }
|
|
|
-
|
|
|
// ---------- 上传 ----------
|
|
|
|
|
|
async function refreshSchoolOptions() {
|
|
|
@@ -702,10 +796,21 @@
|
|
|
|
|
|
// ---------- 初始化 ----------
|
|
|
|
|
|
+ async function loadCatalog() {
|
|
|
+ const { res, body } = await api('/api/ops/fields');
|
|
|
+ if (!res.ok || !body || !Array.isArray(body.fields)) return;
|
|
|
+ state.catalog = body.fields;
|
|
|
+ const valid = new Set(body.fields.map((f) => f.key));
|
|
|
+ state.fields = state.fields.filter((k) => valid.has(k));
|
|
|
+ if (!state.fields.length) state.fields = (body.defaults || []).filter((k) => valid.has(k));
|
|
|
+ if (!state.fields.length) state.fields = body.fields.slice(0, 4).map((f) => f.key);
|
|
|
+ }
|
|
|
+
|
|
|
async function init(force) {
|
|
|
if (state.inited && !force) return;
|
|
|
state.inited = true;
|
|
|
await loadSummary();
|
|
|
+ await loadCatalog();
|
|
|
renderBar();
|
|
|
renderHeaderTools();
|
|
|
renderBody();
|