- shared/utils.js 新增 showConfirm()/showPrompt() 通用函数(inline CSS,无需额外样式) - admin/common.js 移除重复 showConfirm(),统一使用 utils.js - admin/posts.js: 3 处 confirm/prompt 替换(审核/退回/锁定) - admin/comments.js: 1 处 confirm 替换 - settings/index.html: 4 处 confirm + 1 处 prompt 替换 - studio/posts.html + drafts.html: 3 处 confirm 替换 - post-view.js + login.js + post-favorite.js: 3 处 confirm 替换
188 lines
8.6 KiB
JavaScript
188 lines
8.6 KiB
JavaScript
/* ============================================================
|
||
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.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none';
|
||
document.body.appendChild(container);
|
||
}
|
||
var toast = document.createElement('div');
|
||
toast.className = 'toast toast-' + type;
|
||
toast.textContent = message;
|
||
toast.style.cssText = 'background:' + bg + ';color:#fff;padding:10px 24px;border-radius:6px;font-size:14px;pointer-events: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(); });
|
||
}
|
||
|
||
/**
|
||
* 确认弹窗 — 替代原生 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');
|
||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;display:flex;align-items:center;justify-content:center;';
|
||
overlay.innerHTML = '<div style="background:#fff;border-radius:8px;padding:28px 32px;min-width:320px;max-width:440px;box-shadow:0 8px 30px rgba(0,0,0,0.15);">'
|
||
+ '<h3 style="margin:0 0 12px 0;font-size:17px;color:#2d3436;">' + safeTitle + '</h3>'
|
||
+ '<p style="margin:0 0 20px 0;font-size:14px;line-height:1.6;color:#636e72;">' + safeMsg + '</p>'
|
||
+ '<div style="display:flex;gap:10px;justify-content:flex-end;">'
|
||
+ '<button id="mlbModalCancel" style="padding:8px 20px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#374151;font-size:14px;cursor:pointer;">取消</button>'
|
||
+ '<button id="mlbModalConfirm" style="padding:8px 20px;border:none;border-radius:6px;background:#dc2626;color:#fff;font-size:14px;cursor:pointer;">确认</button>'
|
||
+ '</div></div>';
|
||
document.body.appendChild(overlay);
|
||
|
||
function close(val) {
|
||
overlay.remove();
|
||
resolve(val);
|
||
}
|
||
|
||
overlay.querySelector('#mlbModalCancel').onclick = function() { close(false); };
|
||
overlay.querySelector('#mlbModalConfirm').onclick = function() { close(true); };
|
||
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');
|
||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;display:flex;align-items:center;justify-content:center;';
|
||
overlay.innerHTML = '<div style="background:#fff;border-radius:8px;padding:28px 32px;min-width:320px;max-width:440px;box-shadow:0 8px 30px rgba(0,0,0,0.15);">'
|
||
+ '<p style="margin:0 0 16px 0;font-size:14px;line-height:1.6;color:#2d3436;">' + safeMsg + '</p>'
|
||
+ '<input id="mlbPromptInput" type="text" style="width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:14px;color:#2d3436;outline:none;box-sizing:border-box;" value="' + escapeHtml(defaultValue || '') + '" placeholder="">'
|
||
+ '<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px;">'
|
||
+ '<button id="mlbPromptCancel" style="padding:8px 20px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#374151;font-size:14px;cursor:pointer;">取消</button>'
|
||
+ '<button id="mlbPromptConfirm" style="padding:8px 20px;border:none;border-radius:6px;background:#0984e3;color:#fff;font-size:14px;cursor:pointer;">确认</button>'
|
||
+ '</div></div>';
|
||
document.body.appendChild(overlay);
|
||
|
||
var input = overlay.querySelector('#mlbPromptInput');
|
||
|
||
function close(val) {
|
||
overlay.remove();
|
||
resolve(val);
|
||
}
|
||
|
||
overlay.querySelector('#mlbPromptCancel').onclick = function() { close(null); };
|
||
overlay.querySelector('#mlbPromptConfirm').onclick = function() { close(input.value); };
|
||
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);
|