(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 = '
' + '控制台' + '
' + '' + '' + '' + '
' + '
'; 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 : '
暂无日志
'; 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 = ''; else if (item.level === 'error') icon = ''; else if (item.level === 'info') icon = ''; var body = this._renderArgs(item.args, item.id); var count = item.count > 1 ? '' + item.count + '' : ''; return ( '
' + icon + count + '
' + body + '
' + '
' ); }; 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 '' + escapeHtml(text) + ''; }; /** 仅放行浏览器控制台常用的安全样式声明 */ 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 'null'; if (t === 'undefined') return 'undefined'; if (t === 'string') { if (bareString) return '' + escapeHtml(value) + ''; return '"' + escapeHtml(value) + '"'; } if (t === 'number') return '' + String(value) + ''; if (t === 'boolean') return '' + String(value) + ''; if (t === 'bigint') return '' + String(value) + 'n'; if (t === 'symbol') return '' + escapeHtml(String(value)) + ''; if (t === 'function') { var name = value.name || 'anonymous'; return 'ƒ ' + escapeHtml(name) + '()'; } if (value instanceof Error) { var stack = value.stack ? '
' + escapeHtml(value.stack) + '
' : ''; return '' + escapeHtml(value.name + ': ' + value.message) + '' + stack; } if (Array.isArray(value)) return this._renderCollection(value, path, depth, true); if (t === 'object') return this._renderCollection(value, path, depth, false); return '' + escapeHtml(String(value)) + ''; }; 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 = '' + '' + '' + escapeHtml(preview) + '' + ''; 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 += '
…
'; continue; } var child; try { child = value[key]; } catch (err) { child = '⟨access error⟩'; } rows += '
' + '' + escapeHtml(key) + '' + ': ' + this._renderValue(child, path + '.' + key, depth + 1, false) + '
'; } return head + '
' + rows + '
'; }; 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, '"'); } function escapeAttr(s) { return String(s) .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } global.PWConsolePanel = ConsolePanel; })(window);