This repository has been archived on 2026-06-21. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MetaLab/templates/shared/static/js/utils.js
Victor_Jay dd6efd0c50 fix: 修复 utils.js 中 showConfirm/showPrompt/showToast 的 CSP style-src 违规
- showToast: style.cssText 替换为逐个 style.property 赋值
- showConfirm: 移除 style.cssText 和 innerHTML 中的 style 属性,改用 createElement + setStyles 辅助函数 + addEventListener
- showPrompt: 同上,移除所有 inline style 和 onclick
- 新增 setStyles 辅助函数,批量设置单个 style 属性以满足 CSP 要求
2026-06-02 21:45:00 +08:00

297 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
MetaLab 共享工具函数 — 前后台公用,消除 DRY 重复
============================================================ */
'use strict';
/**
* 获取 CSRF 令牌 — 优先从 cookie 读取与服务端验证源一致token 刷新后保持最新),
* meta 标签作为后备(部分页面可能未设置 cookie
* @returns {string} CSRF 令牌,未找到时返回空字符串
*/
function getCSRFToken() {
var match = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/);
if (match && match[1]) return match[1];
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
/**
* HTML 转义 — 使用 DOM API 安全转义,避免 XSS
* @param {string} text — 原始文本
* @returns {string} 转义后的 HTML 安全字符串
*/
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 时间格式化 — ISO 字符串转为 YYYY-MM-DD HH:mm
* @param {string} isoStr — ISO 8601 时间字符串
* @returns {string} 格式化后的时间,无效时返回 '—'
*/
function formatTime(isoStr) {
if (!isoStr) return '—';
var d = new Date(isoStr);
if (isNaN(d.getTime())) return isoStr;
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0') + ' ' +
String(d.getHours()).padStart(2, '0') + ':' +
String(d.getMinutes()).padStart(2, '0');
}
/**
* Toast 通知 — 创建临时提示3 秒后自动淡出移除
* @param {string} message — 提示文本
* @param {string} [type='info'] — 类型success / error / info / warning
*/
function showToast(message, type) {
type = type || 'info';
var colors = { success: '#16a34a', error: '#dc2626', info: 'rgba(0,0,0,0.85)', warning: '#d97706' };
var bg = colors[type] || colors.info;
var container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
container.style.position = 'fixed';
container.style.top = '20px';
container.style.right = '20px';
container.style.zIndex = '9999';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.gap = '8px';
container.style.pointerEvents = 'none';
document.body.appendChild(container);
}
var toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = message;
toast.style.background = bg;
toast.style.color = '#fff';
toast.style.padding = '10px 24px';
toast.style.borderRadius = '6px';
toast.style.fontSize = '14px';
toast.style.pointerEvents = 'auto';
container.appendChild(toast);
setTimeout(function () {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(function () { toast.remove(); }, 300);
}, 3000);
}
/**
* 统一 API 请求封装 — 自动注入 CSRF Token
* @param {string} method — HTTP 方法 (GET/POST/PUT/DELETE)
* @param {string} url — 请求 URL
* @param {object} [data] — 请求体数据GET 请求忽略
* @returns {Promise} fetch 的 response JSON
*/
function api(method, url, data) {
var opts = {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCSRFToken()
}
};
if (data) opts.body = JSON.stringify(data);
return fetch(url, opts).then(function (r) { return r.json(); });
}
/**
* 批量设置元素 style 属性(满足 CSP style-src 限制,避免 cssText 被拦截)
* @param {HTMLElement} el — 目标元素
* @param {object} styles — 键值对 { property: value }
*/
function setStyles(el, styles) {
Object.keys(styles).forEach(function(k) { el.style[k] = styles[k]; });
}
/**
* 确认弹窗 — 替代原生 confirm(),统一全站风格
* @param {string} title — 弹窗标题
* @param {string} message — 提示消息(支持 \n 换行)
* @returns {Promise<boolean>} 用户确认返回 true取消返回 false
*/
function showConfirm(title, message) {
return new Promise(function(resolve) {
var safeTitle = escapeHtml(title);
var safeMsg = escapeHtml(message).replace(/\n/g, '<br>');
// 遮罩层
var overlay = document.createElement('div');
setStyles(overlay, {
position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.4)',
zIndex: '9998', display: 'flex', alignItems: 'center', justifyContent: 'center'
});
// 弹窗容器
var box = document.createElement('div');
setStyles(box, {
background: '#fff', borderRadius: '8px', padding: '28px 32px',
minWidth: '320px', maxWidth: '440px',
boxShadow: '0 8px 30px rgba(0,0,0,0.15)'
});
// 标题
var h3 = document.createElement('h3');
setStyles(h3, { margin: '0 0 12px 0', fontSize: '17px', color: '#2d3436' });
h3.textContent = safeTitle;
box.appendChild(h3);
// 消息
var p = document.createElement('p');
setStyles(p, { margin: '0 0 20px 0', fontSize: '14px', lineHeight: '1.6', color: '#636e72' });
p.innerHTML = safeMsg; // 已转义,安全
box.appendChild(p);
// 按钮容器
var btnRow = document.createElement('div');
setStyles(btnRow, { display: 'flex', gap: '10px', justifyContent: 'flex-end' });
// 取消按钮
var btnCancel = document.createElement('button');
setStyles(btnCancel, {
padding: '8px 20px', border: '1px solid #d1d5db', borderRadius: '6px',
background: '#fff', color: '#374151', fontSize: '14px', cursor: 'pointer'
});
btnCancel.textContent = '取消';
btnCancel.addEventListener('click', function() { close(false); });
btnRow.appendChild(btnCancel);
// 确认按钮
var btnConfirm = document.createElement('button');
setStyles(btnConfirm, {
padding: '8px 20px', border: 'none', borderRadius: '6px',
background: '#dc2626', color: '#fff', fontSize: '14px', cursor: 'pointer'
});
btnConfirm.textContent = '确认';
btnConfirm.addEventListener('click', function() { close(true); });
btnRow.appendChild(btnConfirm);
box.appendChild(btnRow);
overlay.appendChild(box);
document.body.appendChild(overlay);
function close(val) {
overlay.remove();
resolve(val);
}
overlay.addEventListener('click', function(e) {
if (e.target === overlay) close(false);
});
// Enter 键确认
var onKey = function(e) {
if (e.key === 'Enter') { document.removeEventListener('keydown', onKey); close(true); }
if (e.key === 'Escape') { document.removeEventListener('keydown', onKey); close(false); }
};
document.addEventListener('keydown', onKey);
});
}
/**
* 输入弹窗 — 替代原生 prompt(),统一全站风格
* @param {string} message — 提示文本
* @param {string} [defaultValue=''] — 输入框默认值
* @returns {Promise<string|null>} 用户输入的值,取消返回 null
*/
function showPrompt(message, defaultValue) {
return new Promise(function(resolve) {
var safeMsg = escapeHtml(message);
// 遮罩层
var overlay = document.createElement('div');
setStyles(overlay, {
position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.4)',
zIndex: '9998', display: 'flex', alignItems: 'center', justifyContent: 'center'
});
// 弹窗容器
var box = document.createElement('div');
setStyles(box, {
background: '#fff', borderRadius: '8px', padding: '28px 32px',
minWidth: '320px', maxWidth: '440px',
boxShadow: '0 8px 30px rgba(0,0,0,0.15)'
});
// 消息
var p = document.createElement('p');
setStyles(p, { margin: '0 0 16px 0', fontSize: '14px', lineHeight: '1.6', color: '#2d3436' });
p.innerHTML = safeMsg;
box.appendChild(p);
// 输入框
var input = document.createElement('input');
input.type = 'text';
input.id = 'mlbPromptInput';
setStyles(input, {
width: '100%', padding: '10px 12px', border: '1px solid #d1d5db',
borderRadius: '6px', fontSize: '14px', color: '#2d3436',
outline: 'none', boxSizing: 'border-box'
});
input.value = escapeHtml(defaultValue || '');
box.appendChild(input);
// 按钮容器
var btnRow = document.createElement('div');
setStyles(btnRow, { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '16px' });
// 取消按钮
var btnCancel = document.createElement('button');
setStyles(btnCancel, {
padding: '8px 20px', border: '1px solid #d1d5db', borderRadius: '6px',
background: '#fff', color: '#374151', fontSize: '14px', cursor: 'pointer'
});
btnCancel.textContent = '取消';
btnCancel.addEventListener('click', function() { close(null); });
btnRow.appendChild(btnCancel);
// 确认按钮
var btnConfirm = document.createElement('button');
setStyles(btnConfirm, {
padding: '8px 20px', border: 'none', borderRadius: '6px',
background: '#0984e3', color: '#fff', fontSize: '14px', cursor: 'pointer'
});
btnConfirm.textContent = '确认';
btnConfirm.addEventListener('click', function() { close(input.value); });
btnRow.appendChild(btnConfirm);
box.appendChild(btnRow);
overlay.appendChild(box);
document.body.appendChild(overlay);
function close(val) {
overlay.remove();
resolve(val);
}
overlay.addEventListener('click', function(e) {
if (e.target === overlay) close(null);
});
// Enter 确认Escape 取消
var onKey = function(e) {
if (e.key === 'Enter') { document.removeEventListener('keydown', onKey); close(input.value); }
if (e.key === 'Escape') { document.removeEventListener('keydown', onKey); close(null); }
};
document.addEventListener('keydown', onKey);
// 自动聚焦输入框
setTimeout(function() { input.focus(); input.select(); }, 50);
});
}
// 全局头像图片加载失败回退(替代 onerror 内联事件,满足 CSP 无 unsafe-inline
document.addEventListener('error', function(e) {
var img = e.target;
if (img && img.tagName === 'IMG' && (img.classList.contains('nav-avatar') || img.classList.contains('user-avatar-img'))) {
img.style.display = 'none';
var next = img.nextElementSibling;
if (next) next.style.display = 'flex';
}
}, true);