You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

420 lines
17 KiB

(function (global) {
'use strict';
var MAX_DEPTH = 4;
var MAX_KEYS = 40;
var FMT_RE = /%(?:%|[csdifoO])/;
/**
* 贴近浏览器 DevTools 控制台:%c/%s 等格式化、类型着色、可展开对象。
* @constructor
*/
function ConsolePanel() {
this.el = null;
this.list = null;
this.filter = 'all';
this.query = '';
this.buffer = [];
this.capacity = 500;
this._orig = null;
this._expanded = {};
this._onListClick = this._onListClick.bind(this);
}
/** Build DOM and hook console. */
ConsolePanel.prototype.mount = function () {
var self = this;
var root = document.createElement('div');
root.className = 'pw-panel-inner pw-console';
root.innerHTML =
'<div class="pw-panel-head">' +
'<span class="pw-panel-title">控制台</span>' +
'<div class="pw-panel-tools">' +
'<select id="pw-console-level" class="pw-select">' +
'<option value="all">全部级别</option>' +
'<option value="log">日志</option>' +
'<option value="info">信息</option>' +
'<option value="warn">警告</option>' +
'<option value="error">错误</option>' +
'<option value="debug">调试</option>' +
'</select>' +
'<input id="pw-console-q" class="pw-input" placeholder="过滤" style="width:100px" />' +
'<button type="button" id="pw-console-clear" class="pw-btn">清空</button>' +
'</div></div>' +
'<div class="pw-panel-body pw-console-list" id="pw-console-list"></div>';
this.el = root;
this.list = root.querySelector('#pw-console-list');
this.list.addEventListener('click', this._onListClick);
root.querySelector('#pw-console-level').addEventListener('change', function (ev) {
self.filter = ev.target.value;
self.render();
});
root.querySelector('#pw-console-q').addEventListener('input', function (ev) {
self.query = ev.target.value || '';
self.render();
});
root.querySelector('#pw-console-clear').addEventListener('click', function () {
self.buffer.length = 0;
self._expanded = {};
self.render();
});
this._hook();
return root;
};
ConsolePanel.prototype._hook = function () {
if (this._orig) return;
this._orig = {
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
debug: console.debug.bind(console),
};
console.log = this._wrap('log', this._orig.log);
console.info = this._wrap('info', this._orig.info);
console.warn = this._wrap('warn', this._orig.warn);
console.error = this._wrap('error', this._orig.error);
console.debug = this._wrap('debug', this._orig.debug);
};
ConsolePanel.prototype._wrap = function (level, fn) {
var self = this;
return function () {
var args = Array.prototype.slice.call(arguments);
self._push(level, args);
return fn.apply(console, args);
};
};
ConsolePanel.prototype._push = function (level, args) {
var key = level + '|' + this._argsKey(args);
var last = this.buffer.length ? this.buffer[this.buffer.length - 1] : null;
if (last && last._key === key) {
last.count = (last.count || 1) + 1;
this.render();
return;
}
this.buffer.push({
level: level,
args: args,
t: Date.now(),
count: 1,
_key: key,
id: 'c' + Date.now() + '_' + this.buffer.length,
});
if (this.buffer.length > this.capacity) {
var removed = this.buffer.shift();
if (removed) delete this._expanded[removed.id];
}
this.render();
};
ConsolePanel.prototype._argsKey = function (args) {
var parts = [];
for (var i = 0; i < args.length; i++) {
var a = args[i];
var t = typeof a;
if (a === null) parts.push('null');
else if (t === 'string' || t === 'number' || t === 'boolean' || t === 'undefined') parts.push(t + ':' + String(a));
else if (t === 'function') parts.push('fn:' + (a.name || 'anonymous'));
else if (a && a.constructor) parts.push('obj:' + a.constructor.name);
else parts.push(t);
}
return parts.join('\u0001');
};
ConsolePanel.prototype._onListClick = function (ev) {
var toggle = ev.target.closest && ev.target.closest('[data-expand-path]');
if (!toggle) return;
var path = toggle.getAttribute('data-expand-path');
if (!path) return;
this._expanded[path] = !this._expanded[path];
this.render();
};
ConsolePanel.prototype.render = function () {
if (!this.list) return;
var q = this.query.toLowerCase();
var html = '';
var any = false;
for (var i = 0; i < this.buffer.length; i++) {
var item = this.buffer[i];
if (this.filter !== 'all' && item.level !== this.filter) continue;
if (q && this._plainText(item.args).toLowerCase().indexOf(q) < 0) continue;
any = true;
html += this._renderRow(item);
}
this.list.innerHTML = any ? html : '<div class="pw-console-empty">暂无日志</div>';
this.list.scrollTop = this.list.scrollHeight;
};
/** 过滤用纯文本(含格式化后结果,不含 %c 样式串) */
ConsolePanel.prototype._plainText = function (args) {
var formatted = this._tryFormat(args);
if (formatted) {
var text = formatted.plain;
for (var i = 0; i < formatted.rest.length; i++) {
text += ' ' + this._brief(formatted.rest[i]);
}
return text;
}
var parts = [];
for (var j = 0; j < args.length; j++) {
if (typeof args[j] === 'string') parts.push(args[j]);
else parts.push(this._brief(args[j]));
}
return parts.join(' ');
};
ConsolePanel.prototype._renderRow = function (item) {
var icon = '';
if (item.level === 'warn') icon = '<span class="pw-console-icon pw-console-icon-warn" title="warning"></span>';
else if (item.level === 'error') icon = '<span class="pw-console-icon pw-console-icon-error" title="error"></span>';
else if (item.level === 'info') icon = '<span class="pw-console-icon pw-console-icon-info" title="info"></span>';
var body = this._renderArgs(item.args, item.id);
var count = item.count > 1
? '<span class="pw-console-count">' + item.count + '</span>'
: '';
return (
'<div class="pw-console-row level-' + item.level + '">' +
icon + count +
'<div class="pw-console-msg">' + body + '</div>' +
'</div>'
);
};
ConsolePanel.prototype._renderArgs = function (args, rowId) {
var formatted = this._tryFormat(args);
if (formatted) {
var html = formatted.html;
for (var i = 0; i < formatted.rest.length; i++) {
html += (html ? ' ' : '') + this._renderValue(formatted.rest[i], rowId + '.r' + i, 0, false);
}
return html;
}
var out = '';
for (var j = 0; j < args.length; j++) {
if (j) out += ' ';
out += this._renderValue(args[j], rowId + '.' + j, 0, j === 0 && typeof args[j] === 'string');
}
return out;
};
/**
* 解析首参中的 %c/%s/%d/%i/%f/%o/%O(与浏览器 console 一致)。
* @returns {{html:string,plain:string,rest:Array}|null}
*/
ConsolePanel.prototype._tryFormat = function (args) {
if (!args.length || typeof args[0] !== 'string' || !FMT_RE.test(args[0])) return null;
var fmt = args[0];
var ai = 1;
var html = '';
var plain = '';
var style = '';
var i = 0;
while (i < fmt.length) {
if (fmt.charAt(i) === '%' && i + 1 < fmt.length) {
var spec = fmt.charAt(i + 1);
if (spec === '%') {
html += this._styledSpan('%', style);
plain += '%';
i += 2;
continue;
}
if (spec === 'c') {
style = this._sanitizeCss(ai < args.length ? args[ai] : '');
ai++;
i += 2;
continue;
}
var val = ai < args.length ? args[ai] : undefined;
ai++;
if (spec === 's') {
var s = val == null ? 'null' : String(val);
html += this._styledSpan(s, style);
plain += s;
} else if (spec === 'd' || spec === 'i') {
var n = String(parseInt(val, 10) || 0);
html += this._styledSpan(n, style);
plain += n;
} else if (spec === 'f') {
var f = String(Number(val));
html += this._styledSpan(f, style);
plain += f;
} else if (spec === 'o' || spec === 'O') {
var brief = this._brief(val);
html += this._styledSpan(brief, style);
plain += brief;
} else {
html += this._styledSpan('%' + spec, style);
plain += '%' + spec;
}
i += 2;
continue;
}
var start = i;
while (i < fmt.length && fmt.charAt(i) !== '%') i++;
var lit = fmt.slice(start, i);
if (lit) {
html += this._styledSpan(lit, style);
plain += lit;
}
}
return { html: html, plain: plain, rest: args.slice(ai) };
};
ConsolePanel.prototype._styledSpan = function (text, css) {
var styleAttr = css ? ' style="' + escapeAttr(css) + '"' : '';
return '<span class="pw-v-fmt"' + styleAttr + '>' + escapeHtml(text) + '</span>';
};
/** 仅放行浏览器控制台常用的安全样式声明 */
ConsolePanel.prototype._sanitizeCss = function (css) {
if (typeof css !== 'string' || !css) return '';
if (/url\s*\(|expression\s*\(|javascript:|@import|behavior\s*:/i.test(css)) return '';
var allow = {
color: 1,
background: 1,
'background-color': 1,
'font-weight': 1,
'font-style': 1,
'font-size': 1,
'text-decoration': 1,
'text-shadow': 1,
'line-height': 1,
opacity: 1,
};
var parts = css.split(';');
var out = [];
for (var i = 0; i < parts.length; i++) {
var p = parts[i].trim();
if (!p) continue;
var colon = p.indexOf(':');
if (colon <= 0) continue;
var prop = p.slice(0, colon).trim().toLowerCase();
var val = p.slice(colon + 1).trim();
if (!allow[prop] || !val) continue;
if (/url\s*\(|expression|javascript:/i.test(val)) continue;
out.push(prop + ': ' + val);
}
return out.join('; ');
};
ConsolePanel.prototype._renderValue = function (value, path, depth, bareString) {
var t = typeof value;
if (value === null) return '<span class="pw-v-null">null</span>';
if (t === 'undefined') return '<span class="pw-v-undefined">undefined</span>';
if (t === 'string') {
if (bareString) return '<span class="pw-v-text">' + escapeHtml(value) + '</span>';
return '<span class="pw-v-string">"' + escapeHtml(value) + '"</span>';
}
if (t === 'number') return '<span class="pw-v-number">' + String(value) + '</span>';
if (t === 'boolean') return '<span class="pw-v-boolean">' + String(value) + '</span>';
if (t === 'bigint') return '<span class="pw-v-number">' + String(value) + 'n</span>';
if (t === 'symbol') return '<span class="pw-v-symbol">' + escapeHtml(String(value)) + '</span>';
if (t === 'function') {
var name = value.name || 'anonymous';
return '<span class="pw-v-function">ƒ ' + escapeHtml(name) + '()</span>';
}
if (value instanceof Error) {
var stack = value.stack ? '<pre class="pw-console-stack">' + escapeHtml(value.stack) + '</pre>' : '';
return '<span class="pw-v-error">' + escapeHtml(value.name + ': ' + value.message) + '</span>' + stack;
}
if (Array.isArray(value)) return this._renderCollection(value, path, depth, true);
if (t === 'object') return this._renderCollection(value, path, depth, false);
return '<span class="pw-v-text">' + escapeHtml(String(value)) + '</span>';
};
ConsolePanel.prototype._renderCollection = function (value, path, depth, isArray) {
var open = this._expanded[path];
var preview = isArray
? 'Array(' + value.length + ')'
: (value.constructor && value.constructor.name ? value.constructor.name : 'Object');
if (!open) preview += this._previewFlat(value, isArray);
var head =
'<span class="pw-console-toggle' + (open ? ' open' : '') + '" data-expand-path="' + escapeHtml(path) + '">' +
'<span class="pw-console-caret"></span>' +
'<span class="pw-v-object">' + escapeHtml(preview) + '</span>' +
'</span>';
if (!open || depth >= MAX_DEPTH) return head;
var keys = [];
if (isArray) {
for (var i = 0; i < Math.min(value.length, MAX_KEYS); i++) keys.push(String(i));
if (value.length > MAX_KEYS) keys.push('…');
} else {
try { keys = Object.getOwnPropertyNames(value); } catch (e) { keys = []; }
if (keys.length > MAX_KEYS) {
keys = keys.slice(0, MAX_KEYS);
keys.push('…');
}
}
var rows = '';
for (var k = 0; k < keys.length; k++) {
var key = keys[k];
if (key === '…') {
rows += '<div class="pw-console-prop"><span class="pw-v-muted">…</span></div>';
continue;
}
var child;
try { child = value[key]; } catch (err) { child = '⟨access error⟩'; }
rows +=
'<div class="pw-console-prop">' +
'<span class="pw-v-key">' + escapeHtml(key) + '</span>' +
'<span class="pw-v-muted">: </span>' +
this._renderValue(child, path + '.' + key, depth + 1, false) +
'</div>';
}
return head + '<div class="pw-console-children">' + rows + '</div>';
};
ConsolePanel.prototype._previewFlat = function (value, isArray) {
try {
if (isArray) {
var parts = [];
for (var i = 0; i < Math.min(value.length, 5); i++) parts.push(this._brief(value[i]));
if (value.length > 5) parts.push('…');
return ' [' + parts.join(', ') + ']';
}
var keys = Object.keys(value);
var bits = [];
for (var j = 0; j < Math.min(keys.length, 4); j++) {
bits.push(keys[j] + ': ' + this._brief(value[keys[j]]));
}
if (keys.length > 4) bits.push('…');
return ' {' + bits.join(', ') + '}';
} catch (e) {
return '';
}
};
ConsolePanel.prototype._brief = function (v) {
if (v === null) return 'null';
var t = typeof v;
if (t === 'string') return '"' + (v.length > 24 ? v.slice(0, 24) + '…' : v) + '"';
if (t === 'number' || t === 'boolean' || t === 'undefined') return String(v);
if (t === 'function') return 'ƒ';
if (Array.isArray(v)) return 'Array(' + v.length + ')';
if (t === 'object') return '{…}';
return String(v);
};
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeAttr(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
global.PWConsolePanel = ConsolePanel;
})(window);