401 lines
13 KiB
JavaScript
401 lines
13 KiB
JavaScript
// SaaSShop Admin JS
|
||
// 说明:用于增强总台管理的运营交互体验(尽量保持小而可治理)。
|
||
// 原则:不引入复杂构建链;以渐进增强为主,页面无 JS 也应可用。
|
||
|
||
(function () {
|
||
if (window.__SAASSHOP_ADMIN_JS__) {
|
||
return;
|
||
}
|
||
window.__SAASSHOP_ADMIN_JS__ = true;
|
||
|
||
function qs(sel, root) {
|
||
return (root || document).querySelector(sel);
|
||
}
|
||
|
||
function formatMoney(v) {
|
||
var n = Number(v || 0);
|
||
if (!isFinite(n)) {
|
||
n = 0;
|
||
}
|
||
// 统一口径:两位小数 + 千分位
|
||
try {
|
||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
} catch (e) {
|
||
return n.toFixed(2);
|
||
}
|
||
}
|
||
|
||
function formatPct(ratio, digits) {
|
||
var d = (digits == null) ? 1 : Number(digits);
|
||
if (!isFinite(d) || d < 0) {
|
||
d = 1;
|
||
}
|
||
var r = Number(ratio || 0);
|
||
if (!isFinite(r)) {
|
||
r = 0;
|
||
}
|
||
return (r * 100).toFixed(d);
|
||
}
|
||
|
||
// 续费缺订阅治理:绑定成功后自动滚动到顶部提示区(让运营立刻看到 success/warning/error)
|
||
// 说明:由后端 redirect url 追加 attached_subscription=1 触发。
|
||
if (window.location && window.location.search && window.location.search.indexOf('attached_subscription=1') >= 0) {
|
||
try {
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
} catch (e) {
|
||
window.scrollTo(0, 0);
|
||
}
|
||
}
|
||
|
||
// 通用:表单提交后禁用按钮,避免运营重复点击造成重复请求
|
||
// 用法:form 标记 data-action="disable-on-submit"。
|
||
(function () {
|
||
var forms = document.querySelectorAll('form[data-action="disable-on-submit"]');
|
||
if (!forms || forms.length === 0) {
|
||
return;
|
||
}
|
||
|
||
forms.forEach(function (form) {
|
||
form.addEventListener('submit', function () {
|
||
try {
|
||
var btns = form.querySelectorAll('button, input[type="submit"]');
|
||
btns.forEach(function (b) {
|
||
b.disabled = true;
|
||
// 尽量不改文案(避免影响断言/文案口径);只做禁用
|
||
});
|
||
} catch (e) {}
|
||
});
|
||
});
|
||
})();
|
||
|
||
// 通用:折叠面板(collapsible)记忆展开状态(localStorage)
|
||
// 用法:<details data-role="collapsible" data-storage-key="xxx">
|
||
(function () {
|
||
var nodes = document.querySelectorAll('details[data-role="collapsible"][data-storage-key]');
|
||
if (!nodes || nodes.length === 0) {
|
||
return;
|
||
}
|
||
|
||
nodes.forEach(function (d) {
|
||
var key = d.getAttribute('data-storage-key');
|
||
if (!key) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
var saved = window.localStorage.getItem(key);
|
||
if (saved === 'open') {
|
||
d.open = true;
|
||
} else if (saved === 'closed') {
|
||
d.open = false;
|
||
}
|
||
} catch (e) {}
|
||
|
||
d.addEventListener('toggle', function () {
|
||
try {
|
||
window.localStorage.setItem(key, d.open ? 'open' : 'closed');
|
||
} catch (e) {}
|
||
});
|
||
});
|
||
})();
|
||
|
||
// 仪表盘:迷你图表(趋势图渐进增强)
|
||
// 说明:不引入图表库,避免构建链;用 div bar 方式渲染。
|
||
(function () {
|
||
var el = qs('[data-role="platform-order-trend-7d-chart"][data-points]');
|
||
if (!el) {
|
||
return;
|
||
}
|
||
|
||
var raw = el.getAttribute('data-points') || '[]';
|
||
var points = [];
|
||
try {
|
||
points = JSON.parse(raw) || [];
|
||
} catch (e) {
|
||
points = [];
|
||
}
|
||
|
||
if (!points || points.length === 0) {
|
||
el.classList.add('is-empty');
|
||
el.textContent = '暂无趋势数据';
|
||
return;
|
||
}
|
||
|
||
var max = 0;
|
||
points.forEach(function (p) {
|
||
var v = Number(p && p.paid_sum ? p.paid_sum : 0);
|
||
if (v > max) {
|
||
max = v;
|
||
}
|
||
});
|
||
if (!max || max <= 0) {
|
||
max = 1;
|
||
}
|
||
|
||
// 清空(避免 SSR/重复执行污染)
|
||
el.innerHTML = '';
|
||
|
||
points.forEach(function (p) {
|
||
var paid = Number(p && p.paid_sum ? p.paid_sum : 0);
|
||
var h = Math.round(Math.max(6, (paid / max) * 72));
|
||
|
||
var bar = document.createElement('div');
|
||
bar.className = 'adm-mini-chart-bar';
|
||
bar.style.height = h + 'px';
|
||
|
||
var date = (p && p.date) ? String(p.date) : '';
|
||
var countNum = Number(p && p.count != null ? p.count : 0);
|
||
if (!isFinite(countNum) || countNum < 0) {
|
||
countNum = 0;
|
||
}
|
||
var avg = countNum > 0 ? (paid / countNum) : 0;
|
||
bar.title = date + '|订单 ' + String(countNum) + ' 单|已付 ¥' + formatMoney(paid) + '|单均 ¥' + formatMoney(avg);
|
||
|
||
el.appendChild(bar);
|
||
});
|
||
})();
|
||
|
||
// 仪表盘:迷你排行(近7天站点收入 Top5)
|
||
(function () {
|
||
var el = qs('[data-role="merchant-revenue-rank-7d-chart"][data-points]');
|
||
if (!el) {
|
||
return;
|
||
}
|
||
|
||
var raw = el.getAttribute('data-points') || '[]';
|
||
var points = [];
|
||
try {
|
||
points = JSON.parse(raw) || [];
|
||
} catch (e) {
|
||
points = [];
|
||
}
|
||
|
||
if (!points || points.length === 0) {
|
||
el.classList.add('is-empty');
|
||
el.textContent = '暂无排行数据';
|
||
return;
|
||
}
|
||
|
||
var max = 0;
|
||
points.forEach(function (p) {
|
||
var v = Number(p && p.paid_sum ? p.paid_sum : 0);
|
||
if (v > max) {
|
||
max = v;
|
||
}
|
||
});
|
||
if (!max || max <= 0) {
|
||
max = 1;
|
||
}
|
||
|
||
el.innerHTML = '';
|
||
|
||
points.forEach(function (p, idx) {
|
||
var paid = Number(p && p.paid_sum ? p.paid_sum : 0);
|
||
var ratio = Math.max(0, Math.min(1, paid / max));
|
||
|
||
var row = document.createElement('div');
|
||
row.className = 'adm-mini-rank-row';
|
||
|
||
var name = document.createElement('div');
|
||
name.className = 'adm-mini-rank-name';
|
||
var mname = (p && p.name) ? String(p.name) : ('#' + (idx + 1));
|
||
name.textContent = mname;
|
||
// tooltip:显示完整名称(避免 ellipsis 后看不到)
|
||
name.title = mname;
|
||
|
||
var wrap = document.createElement('div');
|
||
wrap.className = 'adm-mini-rank-bar-wrap';
|
||
|
||
var bar = document.createElement('div');
|
||
bar.className = 'adm-mini-rank-bar';
|
||
bar.style.width = Math.round(barRatio * 100) + '%';
|
||
|
||
wrap.appendChild(bar);
|
||
|
||
var val = document.createElement('div');
|
||
val.className = 'adm-mini-rank-value';
|
||
val.textContent = '¥' + formatMoney(paid);
|
||
|
||
var cntText = String(p && p.count != null ? p.count : 0);
|
||
row.title = 'Top' + (idx + 1) + ':' + mname + '|已付 ¥' + formatMoney(paid) + '|订单数 ' + cntText;
|
||
|
||
row.appendChild(name);
|
||
row.appendChild(wrap);
|
||
row.appendChild(val);
|
||
|
||
el.appendChild(row);
|
||
});
|
||
})();
|
||
|
||
// 仪表盘:迷你占比(套餐订单占比 Top5)
|
||
(function () {
|
||
var el = qs('[data-role="plan-order-share-top5-chart"][data-points]');
|
||
if (!el) {
|
||
return;
|
||
}
|
||
|
||
var raw = el.getAttribute('data-points') || '[]';
|
||
var points = [];
|
||
try {
|
||
points = JSON.parse(raw) || [];
|
||
} catch (e) {
|
||
points = [];
|
||
}
|
||
|
||
var total = Number(el.getAttribute('data-total') || 0);
|
||
if (!total || total <= 0) {
|
||
total = 0;
|
||
}
|
||
|
||
if (!points || points.length === 0 || total === 0) {
|
||
el.classList.add('is-empty');
|
||
el.textContent = '暂无占比数据';
|
||
return;
|
||
}
|
||
|
||
// 视觉口径:bar 宽度按 Top5 内最大值归一(更易读);百分比仍按 total 分母计算。
|
||
var maxCnt = 0;
|
||
points.forEach(function (p) {
|
||
var v = Number(p && p.count ? p.count : 0);
|
||
if (v > maxCnt) {
|
||
maxCnt = v;
|
||
}
|
||
});
|
||
if (!maxCnt || maxCnt <= 0) {
|
||
maxCnt = 1;
|
||
}
|
||
|
||
el.innerHTML = '';
|
||
|
||
points.forEach(function (p, idx) {
|
||
var cnt = Number(p && p.count ? p.count : 0);
|
||
var ratio = total > 0 ? Math.max(0, Math.min(1, cnt / total)) : 0;
|
||
var barRatio = maxCnt > 0 ? Math.max(0, Math.min(1, cnt / maxCnt)) : 0;
|
||
|
||
var row = document.createElement('div');
|
||
row.className = 'adm-mini-share-row';
|
||
|
||
var name = document.createElement('div');
|
||
name.className = 'adm-mini-share-name';
|
||
var pname = (p && p.name) ? String(p.name) : ('#' + (idx + 1));
|
||
name.textContent = pname;
|
||
// tooltip:显示完整名称(避免 ellipsis 后看不到)
|
||
name.title = pname;
|
||
|
||
var wrap = document.createElement('div');
|
||
wrap.className = 'adm-mini-share-bar-wrap';
|
||
|
||
var bar = document.createElement('div');
|
||
bar.className = 'adm-mini-share-bar';
|
||
bar.style.width = Math.round(barRatio * 100) + '%';
|
||
|
||
wrap.appendChild(bar);
|
||
|
||
var val = document.createElement('div');
|
||
val.className = 'adm-mini-share-value';
|
||
val.textContent = formatPct(ratio, 1) + '%';
|
||
|
||
row.title = 'Top' + (idx + 1) + ':' + pname + '|' + cnt + ' 单|占比 ' + formatPct(ratio, 1) + '%';
|
||
|
||
row.appendChild(name);
|
||
row.appendChild(wrap);
|
||
row.appendChild(val);
|
||
|
||
el.appendChild(row);
|
||
});
|
||
})();
|
||
|
||
// 通用:将后端 flash 信息同步到 toast(更像 Ant Design Pro 的反馈方式)
|
||
// 说明:渐进增强。页面仍保留原本的提示块,不依赖 JS。
|
||
(function () {
|
||
var container = qs('[data-role="toast-container"]');
|
||
if (!container) {
|
||
return;
|
||
}
|
||
|
||
var flashNodes = document.querySelectorAll('[data-flash]');
|
||
if (!flashNodes || flashNodes.length === 0) {
|
||
return;
|
||
}
|
||
|
||
function createToast(type, text) {
|
||
var div = document.createElement('div');
|
||
div.className = 'toast toast-' + type;
|
||
div.setAttribute('role', 'status');
|
||
|
||
var content = document.createElement('div');
|
||
content.className = 'toast-content';
|
||
content.textContent = text;
|
||
|
||
var close = document.createElement('button');
|
||
close.type = 'button';
|
||
close.className = 'toast-close';
|
||
close.textContent = '×';
|
||
close.addEventListener('click', function () {
|
||
try {
|
||
container.removeChild(div);
|
||
} catch (e) {}
|
||
});
|
||
|
||
div.appendChild(content);
|
||
div.appendChild(close);
|
||
return div;
|
||
}
|
||
|
||
flashNodes.forEach(function (node) {
|
||
var type = node.getAttribute('data-flash') || 'info';
|
||
var text = (node.textContent || '').trim();
|
||
if (!text) {
|
||
return;
|
||
}
|
||
|
||
container.appendChild(createToast(type, text));
|
||
});
|
||
|
||
// 自动消失(success/warning),error 保留更久
|
||
setTimeout(function () {
|
||
try {
|
||
var toasts = container.querySelectorAll('.toast');
|
||
toasts.forEach(function (t) {
|
||
var cls = t.className || '';
|
||
var isError = cls.indexOf('toast-error') >= 0;
|
||
if (!isError) {
|
||
container.removeChild(t);
|
||
}
|
||
});
|
||
} catch (e) {}
|
||
}, 4500);
|
||
|
||
setTimeout(function () {
|
||
try {
|
||
var toasts = container.querySelectorAll('.toast');
|
||
toasts.forEach(function (t) {
|
||
try {
|
||
container.removeChild(t);
|
||
} catch (e) {}
|
||
});
|
||
} catch (e) {}
|
||
}, 9000);
|
||
})();
|
||
|
||
// 续费缺订阅治理:订单详情页“绑定订阅ID”输入框,小交互增强:
|
||
// - 输入后按 Enter 直接提交
|
||
// - 自动聚焦,减少点击
|
||
var attachInput = qs('#attach_site_subscription_id');
|
||
if (attachInput) {
|
||
try {
|
||
attachInput.focus();
|
||
} catch (e) {}
|
||
|
||
attachInput.addEventListener('keydown', function (e) {
|
||
if (e && (e.key === 'Enter' || e.keyCode === 13)) {
|
||
var form = attachInput.form;
|
||
if (form) {
|
||
e.preventDefault();
|
||
form.submit();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
})();
|