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 4442da31c3 fix: 收藏夹弹窗优化 — 已收藏标识 + 移除确认 + 点击可靠性
- 收藏夹选项中已收藏项显示黄色'已收藏'徽章,一目了然
- 点击判断改用 classList.contains('active') 替代 JS 变量,防 currentFolderId 为空
- addToFolder 兜底处理'已在此收藏夹中'错误,不弹 alert
- 取消收藏确认文案优化为'确定移出该收藏夹吗?'
2026-06-03 00:04:29 +08:00

297 lines
11 KiB
JavaScript
Raw Permalink 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: '10000', 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: '10000', 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);