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
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
global.PWConsolePanel = ConsolePanel;
|
|
})(window);
|
|
|