您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Tampermonkey plugin for displaying ChatGPT historical and real-time conversation time. 显示 ChatGPT 历史对话时间 与 实时对话时间的 Tampermonkey 插件。
当前为
// ==UserScript== // @name ChatGPT with Date // @namespace https://github.com/jiang-taibai/chatgpt-with-date // @version 1.1.0 // @description Tampermonkey plugin for displaying ChatGPT historical and real-time conversation time. 显示 ChatGPT 历史对话时间 与 实时对话时间的 Tampermonkey 插件。 // @author CoderJiang // @license MIT // @match https://chat.openai.com/* // @icon https://cdn.coderjiang.com/project/chatgpt-with-date/logo.svg // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @run-at document-end // ==/UserScript== (function () { 'use strict'; class SystemConfig { static TimeRender = { Interval: 1000, TimeClassName: 'chatgpt-time', Selectors: [{ Selector: '.chatgpt-time', Style: { 'font-size': '14px', 'color': '#666', 'margin-left': '5px', 'font-weight': 'normal', } }], RenderRetryCount: 3, RenderModes: ['AfterRoleLeft', 'AfterRoleRight', 'BelowRole'], RenderModeStyles: { 'AfterRoleLeft': { 'font-size': '14px', 'color': '#666', 'margin-left': '5px', 'font-weight': 'normal', }, 'AfterRoleRight': { 'font-size': '14px', 'color': '#666', 'font-weight': 'normal', 'float': 'right' }, 'BelowRole': { 'font-size': '14px', 'color': '#666', 'font-weight': 'normal', 'display': 'block', }, }, TimeTagTemplates: [ `<span>{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}</span>`, `<span>{MM}/{dd}/{yyyy} {HH}:{mm}:{ss}</span>`, `<span>{MM}-{dd} {HH}:{mm}:{ss}</span>`, `<span>{MM}-{dd} {HH}:{mm}</span>`, `<span>{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}.{ms}</span>`, `<span style="background: #2B2B2b;border-radius: 8px;padding: 1px 10px;color: #717171;">{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}</span>`, `<span style="border-radius: 8px; color: #E0E0E0; font-size: 0.9em; overflow: hidden; display: inline-block;"><span style="background: #333; padding: 2px 4px 2px 10px; display: inline-block;">{yyyy}-{MM}-{dd}</span><span style="background: #606060; padding: 2px 10px 2px 4px; display: inline-block;">{HH}:{mm}:{ss}</span></span>`, ], } static ConfigPanel = { AppID: 'CWD-Configuration-Panel', Icon: { Close: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M649.179 512l212.839-212.84c37.881-37.881 37.881-99.298 0-137.179s-99.298-37.881-137.179 0L512 374.821l-212.839-212.84c-37.881-37.881-99.298-37.881-137.179 0s-37.881 99.298 0 137.179L374.821 512 161.982 724.84c-37.881 37.881-37.881 99.297 0 137.179 18.94 18.94 43.765 28.41 68.589 28.41 24.825 0 49.649-9.47 68.589-28.41L512 649.179l212.839 212.84c18.94 18.94 43.765 28.41 68.589 28.41s49.649-9.47 68.59-28.41c37.881-37.882 37.881-99.298 0-137.179L649.179 512z"></path></svg>', } } // GM 存储的键 static GMStorageKey = { UserConfig: 'ChatGPTWithDate-UserConfig', ConfigPanel: { Position: 'ChatGPTWithDate-ConfigPanel-Position', Size: 'ChatGPTWithDate-ConfigPanel-Size', }, } } class Logger { static EnableLog = true static EnableDebug = true static EnableInfo = true static EnableWarn = true static EnableError = true static EnableTable = true static log(...args) { if (Logger.EnableLog) { console.log(...args); } } static debug(...args) { if (Logger.EnableDebug) { console.debug(...args); } } static info(...args) { if (Logger.EnableInfo) { console.info(...args); } } static warn(...args) { if (Logger.EnableWarn) { console.warn(...args); } } static error(...args) { if (Logger.EnableError) { console.error(...args); } } static table(...args) { if (Logger.EnableTable) { console.table(...args); } } } class Utils { /** * 计算时间 * * @param timestamp 时间戳,浮点数或整数类型,单位毫秒,例如 1714398759.26881、1714398759 * @returns {{milliseconds: number, hours: number, seconds: number, month: number, year: number, minutes: number, day: number}} */ static calculateTime(timestamp) { const date = new Date(timestamp); const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); const hours = date.getHours(); const minutes = date.getMinutes(); const seconds = date.getSeconds(); const milliseconds = date.getMilliseconds(); return { year, month, day, hours, minutes, seconds, milliseconds }; } /** * 格式化日期时间 * @param year 年份 * @param month 月份 * @param day 日期 * @param hour 小时 * @param minute 分钟 * @param second 秒 * @param milliseconds 毫秒 * @param template 模板,例如 'yyyy-MM-dd HH:mm:ss' * @returns string 格式化后的日期时间字符串 */ static formatDateTime(year, month, day, hour, minute, second, milliseconds, template) { return template .replace('{yyyy}', year) .replace('{MM}', month.toString().padStart(2, '0')) .replace('{dd}', day.toString().padStart(2, '0')) .replace('{HH}', hour.toString().padStart(2, '0')) .replace('{mm}', minute.toString().padStart(2, '0')) .replace('{ss}', second.toString().padStart(2, '0')) .replace('{ms}', milliseconds.toString().padStart(3, '0')); } /** * 检查依赖关系图(有向图)是否有循环依赖,如果没有就返回一个先后顺序(即按照此顺序实例化不会出现依赖项为空的情况)。 * 给定依赖关系图为此结构[{node:'ComponentClass0', dependencies:['ComponentClass2', 'ComponentClass3']}, ...] * @param dependencyGraph 依赖关系图 * @returns {*[]} */ static dependencyAnalysis(dependencyGraph) { // 创建一个映射每个节点到其入度的对象 const inDegree = {}; const graph = {}; const order = []; // 初始化图和入度表 dependencyGraph.forEach(item => { const {node, dependencies} = item; if (!graph[node]) { graph[node] = []; inDegree[node] = 0; } dependencies.forEach(dependentNode => { if (!graph[dependentNode]) { graph[dependentNode] = []; inDegree[dependentNode] = 0; } graph[dependentNode].push(node); inDegree[node]++; }); }); // 将所有入度为0的节点加入到队列中 const queue = []; for (const node in inDegree) { if (inDegree[node] === 0) { queue.push(node); } } // 处理队列中的节点 while (queue.length) { const current = queue.shift(); order.push(current); graph[current].forEach(neighbour => { inDegree[neighbour]--; if (inDegree[neighbour] === 0) { queue.push(neighbour); } }); } // 如果排序后的节点数量不等于图中的节点数量,说明存在循环依赖 if (order.length !== Object.keys(graph).length) { // 找到循环依赖的节点 const cycleNodes = []; for (const node in inDegree) { if (inDegree[node] !== 0) { cycleNodes.push(node); } } throw new Error("存在循环依赖的节点:" + cycleNodes.join(",")); } return order; } } class MessageBO { /** * 消息业务对象 * * @param messageId 消息ID,为消息元素的data-message-id属性值 * @param role 角色 * system: 表示系统消息,并不属于聊天内容。 从 API 获取 * tool: 也表示系统消息。 从 API 获取 * assistant: 表示 ChatGPT 回答的消息。 从 API 获取 * user: 表示用户输入的消息。 从 API 获取 * You: 表示用户输入的消息。 从页面实时获取 * ChatGPT: 表示 ChatGPT 回答的消息。 从页面实时获取 * @param timestamp 时间戳,浮点数或整数类型,单位毫秒,例如 1714398759.26881、1714398759 * @param message 消息内容 */ constructor(messageId, role, timestamp, message = '') { this.messageId = messageId; this.role = role; this.timestamp = timestamp; this.message = message; } } class MessageElementBO { /** * 消息元素业务对象 * * @param rootEle 消息的根元素,并非是总根元素,而是 roleEle 和 messageEle 的最近公共祖先元素 * @param roleEle 角色元素,例如 <div>ChatGPT</div> * @param messageEle 消息元素,包含 data-message-id 属性 * 例如 <div data-message-id="123456">你好</div> */ constructor(rootEle, roleEle, messageEle) { this.rootEle = rootEle; this.roleEle = roleEle; this.messageEle = messageEle; } } class Component { constructor() { this.dependencies = [] Object.defineProperty(this, 'initDependencies', { value: function () { this.dependencies.forEach(dependency => { this[dependency.field] = ComponentLocator.get(dependency.clazz) }) }, writable: false, // 防止方法被修改 configurable: false // 防止属性被重新定义或删除 }); } init() { } } class ComponentLocator { /** * 组件注册(不可用)器,用于注册(不可用)和获取组件 */ static components = {}; /** * 注册(不可用)组件,要求组件为 Component 的子类 * * @param clazz Component 的子类 * @param instance Component 的子类的实例化对象,必顧是 clazz 的实例 * @returns obj 返回注册(不可用)的实例化对象 */ static register(clazz, instance) { if (!(instance instanceof Component)) { throw new Error(`实例化对象 ${instance} 不是 Component 的实例。`); } if (!(instance instanceof clazz)) { throw new Error(`实例化对象 ${instance} 不是 ${clazz} 的实例。`); } if (ComponentLocator.components[clazz.name]) { throw new Error(`组件 ${clazz.name} 已经注册(不可用)过了。`); } ComponentLocator.components[clazz.name] = instance; return instance } /** * 获取组件,用于完成组件之间的依赖注入 * * @param clazz Component 的子类 * @returns {*} 返回注册(不可用)的实例化对象 */ static get(clazz) { if (!ComponentLocator.components[clazz.name]) { throw new Error(`组件 ${clazz.name} 未注册(不可用)。`); } return ComponentLocator.components[clazz.name]; } } class UserConfig extends Component { init() { this.timeRender = { mode: 'AfterRoleLeft', format: SystemConfig.TimeRender.TimeTagTemplates[0], } const userConfig = this.load() if (userConfig) { Object.assign(this.timeRender, userConfig.timeRender) } } save() { GM_setValue(SystemConfig.GMStorageKey.UserConfig, { timeRender: this.timeRender }) } load() { return GM_getValue(SystemConfig.GMStorageKey.UserConfig, {}) } /** * 更新配置并保存 * @param newConfig 新的配置 */ update(newConfig) { Object.assign(this.timeRender, newConfig.timeRender) this.save() } } class StyleService extends Component { init() { this.styles = new Map() this._initStyleElement() this._reRenderStyle() } /** * 初始化样式元素,该元素用于存放动态生成的样式 * @private */ _initStyleElement() { const styleElement = document.createElement('style'); styleElement.type = 'text/css'; document.head.appendChild(styleElement); this.styleEle = styleElement; } /** * 更新样式选择器的样式,合并原有样式和新样式 * * @param selector 选择器,例如 '.chatgpt-time' 表示选择 class 为 chatgpt-time 的元素 * @param style 样式,字典对象,例如 {'font-size': '14px', 'color': '#666'} */ updateStyle(selector, style) { const newStyle = Object.assign({}, this.styles.get(selector), style) this.styles.set(selector, newStyle) this._reRenderStyle() } /** * 重置一个样式选择器的样式,覆盖原有样式 * * @param selector 选择器,例如 '.chatgpt-time' 表示选择 class 为 chatgpt-time 的元素 * @param style 样式,字典对象,例如 {'font-size': '14px', 'color': '#666'} */ resetOneSelector(selector, style) { this.styles.set(selector, style) this._reRenderStyle() } /** * 重置多个样式选择器的样式,覆盖原有样式 * * @param selectors 选择器数组,例如 ['.chatgpt-time', '.chatgpt-time2'] * @param styles 样式数组,例如 [{'font-size': '14px', 'color': '#666'}, {'font-size': '16px', 'color': '#666'}] */ resetBatchSelectors(selectors, styles) { for (let i = 0; i < selectors.length; i++) { this.styles.set(selectors[i], styles[i]) } this._reRenderStyle() } /** * 重新渲染样式,即把 this.styles 中的样式同步到 style 元素中。 * 该方法会清空原有的样式,然后重新生成。 * @private */ _reRenderStyle() { let styleText = '' for (let [selector, style] of this.styles) { let styleStr = '' for (let [key, value] of Object.entries(style)) { styleStr += `${key}: ${value};` } styleText += `${selector} {${styleStr}}` } this.styleEle.innerHTML = styleText } } class MessageService extends Component { init() { this.messages = new Map(); } /** * 解析消息元素,获取消息的所有内容。由于网页中不存在时间戳,所以时间戳使用当前时间代替。 * 调用该方法只需要消息元素,一般用于从页面实时监测获取到的消息。 * * @param messageDiv 消息元素,包含 data-message-id 属性 的 div 元素 * @returns {MessageBO|undefined} 返回消息业务对象,如果消息元素不存在则返回 undefined */ parseMessageDiv(messageDiv) { if (!messageDiv) { return; } const messageId = messageDiv.getAttribute('data-message-id'); const messageElementBO = this.getMessageElement(messageId) if (!messageElementBO) { return; } let timestamp = new Date().getTime(); const role = messageElementBO.roleEle.innerText; const message = messageElementBO.messageEle.innerHTML; if (!this.messages.has(messageId)) { const messageBO = new MessageBO(messageId, role, timestamp, message); this.messages.set(messageId, messageBO); } return this.messages.get(messageId); } /** * 添加消息,主要用于添加从 API 劫持到的消息列表。 * 调用该方法需要已知消息的所有内容,如果只知道消息元素则应该使用 parseMessageDiv 方法获取消息业务对象。 * * @param message 消息业务对象 * @param force 是否强制添加,如果为 true 则强制添加,否则如果消息已经存在则不添加 * @returns {boolean} 返回是否添加成功 */ addMessage(message, force = false) { if (this.messages.has(message.messageId) && !force) { return false; } this.messages.set(message.messageId, message); return true } /** * 通过消息 ID 获取消息元素业务对象 * * @param messageId 消息 ID * @returns {MessageElementBO|undefined} 返回消息元素业务对象 */ getMessageElement(messageId) { const messageDiv = document.body.querySelector(`div[data-message-id="${messageId}"]`); if (!messageDiv) { return; } const rootDiv = messageDiv.parentElement.parentElement.parentElement; const roleDiv = rootDiv.firstChild; return new MessageElementBO(rootDiv, roleDiv, messageDiv); } /** * 通过消息 ID 获取消息业务对象 * @param messageId * @returns {any} */ getMessage(messageId) { return this.messages.get(messageId); } /** * 显示所有消息信息 */ showMessages() { Logger.table(Array.from(this.messages.values())); } } class MonitorService extends Component { constructor() { super(); this.messageService = null this.timeRendererService = null this.dependencies = [{field: 'messageService', clazz: MessageService}, { field: 'timeRendererService', clazz: TimeRendererService },] } init() { this.totalTime = 0; this.originalFetch = window.fetch; this._initMonitorFetch(); this._initMonitorAddedMessageNode(); } /** * 初始化劫持 fetch 方法,用于监控 ChatGPT 的消息数据 * * @private */ _initMonitorFetch() { const that = this; unsafeWindow.fetch = (...args) => { return that.originalFetch.apply(this, args) .then(response => { // 克隆响应对象以便独立处理响应体 const clonedResponse = response.clone(); if (response.url.includes('https://chat.openai.com/backend-api/conversation/')) { clonedResponse.json().then(data => { that._parseConversationJsonData(data); }).catch(error => Logger.error('解析响应体失败:', error)); } return response; }); }; } /** * 解析从 API 获取到的消息数据,该方法存在报错风险,需要在调用时捕获异常以防止中断后续操作。 * * @param obj 从 API 获取到的消息数据 * @private */ _parseConversationJsonData(obj) { const mapping = obj.mapping const messageIds = [] for (let key in mapping) { const message = mapping[key].message if (message) { const messageId = message.id const role = message.author.role const createTime = message.create_time const messageBO = new MessageBO(messageId, role, createTime * 1000) messageIds.push(messageId) this.messageService.addMessage(messageBO, true) } } this.timeRendererService.addMessageArrayToBeRendered(messageIds) this.messageService.showMessages() } /** * 初始化监控节点变化,用于监控在使用 ChatGPT 期间实时输入的消息。 * 每隔 500ms 检查一次 main 节点是否存在,如果存在则开始监控节点变化。 * @private */ _initMonitorAddedMessageNode() { const interval = setInterval(() => { const mainElement = document.querySelector('main'); if (mainElement) { this._setupMonitorAddedMessageNode(mainElement); clearInterval(interval); // 清除定时器,停止进一步检查 } }, 500); } /** * 设置监控节点变化,用于监控在使用 ChatGPT 期间实时输入的消息。 * @param supervisedNode 监控在此节点下的节点变化,确保新消息的节点在此节点下 * @private */ _setupMonitorAddedMessageNode(supervisedNode) { const that = this; const callback = function (mutationsList, observer) { const start = new Date().getTime(); for (const mutation of mutationsList) { if (mutation.type === 'childList') { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { let messageDiv = node.querySelector('div[data-message-id]'); if (!messageDiv && node.hasAttribute('data-message-id')) { messageDiv = node } if (messageDiv !== null) { const messageBO = that.messageService.parseMessageDiv(messageDiv); that.timeRendererService.addMessageToBeRendered(messageBO.messageId); that.messageService.showMessages() } } }); } } const end = new Date().getTime(); that.totalTime += (end - start); Logger.debug(`监控到节点变化,耗时 ${end - start}ms,总耗时 ${that.totalTime}ms。`); }; const observer = new MutationObserver(callback); observer.observe(supervisedNode, {childList: true, subtree: true,}); } } class TimeRendererService extends Component { constructor() { super(); this.messageService = null this.userConfig = null this.styleService = null this.dependencies = [{field: 'messageService', clazz: MessageService}, { field: 'userConfig', clazz: UserConfig }, {field: 'styleService', clazz: StyleService},] } init() { this.messageToBeRendered = [] this.messageCountOfFailedToRender = new Map() this._initStyle() this._initRender() } /** * 初始化与时间有关的样式 * @private */ _initStyle() { this.styleService.resetBatchSelectors(SystemConfig.TimeRender.Selectors.map(item => item.Selector), SystemConfig.TimeRender.Selectors.map(item => item.Style)) this.styleService.resetOneSelector(`.${SystemConfig.TimeRender.TimeClassName}`, SystemConfig.TimeRender.RenderModeStyles[this.userConfig.timeRender.mode]) } /** * 添加消息 ID 到待渲染队列 * @param messageId 消息 ID */ addMessageToBeRendered(messageId) { if (typeof messageId !== 'string') { return } this.messageToBeRendered.push(messageId) Logger.debug(`添加ID ${messageId} 到待渲染队列,当前队列 ${this.messageToBeRendered}`) } /** * 添加消息 ID 到待渲染队列 * @param messageIdArray 消息 ID数组 */ addMessageArrayToBeRendered(messageIdArray) { if (!messageIdArray || !(messageIdArray instanceof Array)) { return } messageIdArray.forEach(messageId => this.addMessageToBeRendered(messageId)) } /** * 初始化渲染时间的定时器,每隔 SystemConfig.TimeRender.Interval 毫秒处理一次待渲染队列 * 1. 备份待渲染队列 * 2. 清空待渲染队列 * 3. 遍历备份的队列,逐个渲染 * 3.1 如果渲染失败则重新加入待渲染队列,失败次数加一 * 3.2 如果渲染成功,清空失败次数 * 4. 重复 1-3 步骤 * 5. 如果失败次数超过 SystemConfig.TimeRender.RenderRetryCount 则不再尝试渲染,即不再加入待渲染队列。同时清空失败次数。 * * @private */ _initRender() { const that = this function processTimeRender() { const start = new Date().getTime(); let completeCount = 0; let totalCount = that.messageToBeRendered.length; const messageToBeRenderedClone = that.messageToBeRendered.slice() that.messageToBeRendered = [] for (let messageId of messageToBeRenderedClone) { new Promise(resolve => { resolve(that._renderTime(messageId)) }).then((result) => { if (!result) { Logger.debug(`ID ${messageId} 渲染失败,当前渲染进度 ${completeCount}/${totalCount}`) let count = that.messageCountOfFailedToRender.get(messageId) if (count && count >= SystemConfig.TimeRender.RenderRetryCount) { Logger.debug(`ID ${messageId} 渲染失败次数超过 ${SystemConfig.TimeRender.RenderRetryCount} 次,将不再尝试。`) that.messageCountOfFailedToRender.delete(messageId) } else { that.messageToBeRendered.push(messageId); if (count) { that.messageCountOfFailedToRender.set(messageId, count + 1) } else { that.messageCountOfFailedToRender.set(messageId, 1) } } } else { completeCount++ Logger.debug(`ID ${messageId} 渲染完成,当前渲染进度 ${completeCount}/${totalCount}`) that.messageCountOfFailedToRender.delete(messageId) } }) } const end = new Date().getTime(); Logger.debug(`处理当前ID队列渲染 ${messageToBeRenderedClone} 耗时 ${end - start}ms`) setTimeout(processTimeRender, SystemConfig.TimeRender.Interval); } processTimeRender() } /** * 将时间渲染到目标位置,如果检测到目标位置已经存在时间元素则更新时间,否则创建时间元素并插入到目标位置。 * * @param messageId 消息 ID * @returns {boolean} 返回是否渲染成功 * @private */ _renderTime(messageId) { const messageElementBo = this.messageService.getMessageElement(messageId); const messageBo = this.messageService.getMessage(messageId); if (!messageElementBo || !messageBo) return false; const timeElement = messageElementBo.rootEle.querySelector(`.${SystemConfig.TimeRender.TimeClassName}`); const element = this._createTimeElement(messageBo.timestamp); if (!timeElement) { switch (this.userConfig.timeRender.mode) { case 'AfterRoleLeft': case 'AfterRoleRight': case 'BelowRole': messageElementBo.roleEle.innerHTML += element.timeElementHTML break; } } else { timeElement.innerHTML = element.timeString } return true; } /** * 创建时间元素 * * @param timestamp 时间戳,浮点数或整数类型,单位毫秒,例如 1714398759.26881、1714398759 * @returns {{timeString, timeElementHTML: string}} 返回时间字符串和时间元素的 HTML * @private */ _createTimeElement(timestamp) { const time = Utils.calculateTime(timestamp); const timeString = Utils.formatDateTime(time.year, time.month, time.day, time.hours, time.minutes, time.seconds, time.milliseconds, this.userConfig.timeRender.format); let timeElementHTML = `<span class="${SystemConfig.TimeRender.TimeClassName}">${timeString}</span>`; return { timeString, timeElementHTML, }; } /** * 清除所有时间元素 * @private */ _cleanAllTimeElements() { const timeElements = document.body.querySelectorAll(`.${SystemConfig.TimeRender.TimeClassName}`); timeElements.forEach(ele => { ele.remove() }) } /** * 重新渲染时间元素,强制拉取所有消息 ID 重新渲染 */ reRender() { this.styleService.resetOneSelector(`.${SystemConfig.TimeRender.TimeClassName}`, SystemConfig.TimeRender.RenderModeStyles[this.userConfig.timeRender.mode]) this._cleanAllTimeElements() this.addMessageArrayToBeRendered(Array.from(this.messageService.messages.keys())) } } class ConfigPanelService extends Component { constructor() { super(); this.userConfig = null this.timeRendererService = null this.messageService = null this.dependencies = [{field: 'userConfig', clazz: UserConfig}, { field: 'timeRendererService', clazz: TimeRendererService }, {field: 'messageService', clazz: MessageService},] } /** * 初始化配置面板,强调每个子初始化方法阻塞式的执行,即一个初始化方法执行完毕后再执行下一个初始化方法。 * @returns {Promise<void>} */ async init() { this.appID = SystemConfig.ConfigPanel.AppID this._initVariables() Logger.debug('开始初始化配置面板') await this._initStyle() Logger.debug('初始化样式完成') await this._initScript() Logger.debug('初始化脚本完成') await this._initPanel() Logger.debug('初始化面板完成') this._initVue() Logger.debug('初始化Vue完成') this._initConfigPanelSizeAndPosition() this._initConfigPanelEventMonitor() Logger.debug('初始化配置面板事件监控完成') this._initMenuCommand() Logger.debug('初始化菜单命令完成') } /** * 初始化配置面板的 HTML 与 Vue 实例的配置属性。集中管理以便方便修改。 * @private */ _initVariables() { const that = this this.panelHTML = ` <div id="${that.appID}" style="visibility: hidden"> <div class="status-bar"> <div class="title" id="${that.appID}-DraggableArea">{{title}}</div> <n-button class="close" @click="onClose" text> <n-icon size="20"> ${SystemConfig.ConfigPanel.Icon.Close} </n-icon> </n-button> </div> <div class="container"> <n-form :label-width="40" :model="configForm" label-placement="left"> <n-form-item label="样式" path="format"> <n-select v-model:value="configForm.format" filterable tag :options="formatOptions" @update:value="onConfigUpdate"></n-select> </n-form-item> <n-form-item label="预览" path="format"> <div v-html="reFormatTimeHTML(configForm.format)"></div> </n-form-item> <n-form-item label="位置" path="mode"> <n-select v-model:value="configForm.mode" :options="modeOptions" @update:value="onConfigUpdate"></n-select> </n-form-item> </n-form> <div class="button-group"> <n-button @click="onReset" :disabled="!configDirty">重置</n-button> <n-button @click="onApply">应用</n-button> <n-button @click="onConfirm">保存</n-button> </div> </div> </div>` this.appConfig = { el: `#${that.appID}`, data() { return { timestamp: new Date().getTime(), title: "ChatGPTWithDate", formatOptions: SystemConfig.TimeRender.TimeTagTemplates.map(item => { return {label: item, value: item} }), modeOptions: [{label: '角色之后(靠左)', value: 'AfterRoleLeft'}, { label: '角色之后(居右)', value: 'AfterRoleRight' }, {label: '角色之下', value: 'BelowRole'},], configForm: { format: that.userConfig.timeRender.format, mode: that.userConfig.timeRender.mode, }, config: { format: that.userConfig.timeRender.format, mode: that.userConfig.timeRender.mode, }, configDirty: false, configPanel: { display: true, }, }; }, methods: { onApply() { this.config = Object.assign({}, this.configForm); that.updateConfig(this.config) this.configDirty = false }, onConfirm() { this.onApply() this.onClose() }, onReset() { this.configForm = Object.assign({}, this.config); this.configDirty = false }, onClose() { that.hide() }, onConfigUpdate() { this.configDirty = true }, renderTag({option, handleClose}) { return Vue.h('div', { innerHTML: this.reFormatTimeHTML(option.value) }); }, renderLabel(option) { return Vue.h('div', { innerHTML: this.reFormatTimeHTML(option.value) }); }, reFormatTimeHTML(html) { const { year, month, day, hours, minutes, seconds, milliseconds } = Utils.calculateTime(this.timestamp) return Utils.formatDateTime(year, month, day, hours, minutes, seconds, milliseconds, html) }, }, created() { this.timestamp = new Date().getTime() this.formatOptions.forEach(item => { item.label = this.reFormatTimeHTML(item.value) }) }, mounted() { this.timestampInterval = setInterval(() => { this.timestamp = new Date().getTime() this.formatOptions.forEach(item => { item.label = this.reFormatTimeHTML(item.value) }) }, 50) }, } } /** * 初始化样式。 * 同时为了避免样式冲突,在 head 元素中加入一个 <meta name="naive-ui-style" /> 元素, * naive-ui 会把所有的样式刚好插入这个元素的前面。 * 参考 https://www.naiveui.com/zh-CN/os-theme/docs/style-conflict * * @returns {Promise} * @private */ _initStyle() { return new Promise(resolve => { const meta = document.createElement('meta'); meta.name = 'naive-ui-style' document.head.appendChild(meta); const style = ` .v-binder-follower-container { position: fixed; } #CWD-Configuration-Panel { position: absolute; top: 50px; left: 50px; width: 250px; background-color: #FFFFFF; border: #D7D8D9 1px solid; border-radius: 4px; resize: horizontal; min-width: 200px; overflow: auto; color: black; opacity: 0.9; } #CWD-Configuration-Panel .status-bar { cursor: move; background-color: #f0f0f0; border-radius: 4px 4px 0 0; display: flex; } #CWD-Configuration-Panel .status-bar .title { display: flex; align-items: center; justify-content: left; padding-left: 10px; user-select: none; color: #777; flex: 1; font-weight: bold; } #CWD-Configuration-Panel .status-bar .close { cursor: pointer; padding: 10px; transition: color 0.3s; } #CWD-Configuration-Panel .status-bar .close:hover { color: #f00; } #CWD-Configuration-Panel .container { padding: 20px; } #CWD-Configuration-Panel .container .button-group { display: flex; justify-content: center; gap: 10px; } #CWD-Configuration-Panel .container .button-group > button { width: 30%; }` const styleEle = document.createElement('style'); styleEle.type = 'text/css' styleEle.innerHTML = style; document.head.appendChild(styleEle); resolve() }) } /** * 初始化 Vue 与 Naive UI 脚本。无法使用 <script src="https://xxx"> 的方式插入,因为 ChatGPT 有 CSP 限制。 * 采用 GM_xmlhttpRequest 的方式获取 Vue 与 Naive UI 的脚本内容,然后插入 <script>脚本内容</script> 到页面中。 * * @returns {Promise} * @private */ _initScript() { return new Promise(resolve => { let completeCount = 0; const addScript = (content) => { let script = document.createElement('script'); script.textContent = content; document.body.appendChild(script); completeCount++; if (completeCount === 2) { resolve() } } GM_xmlhttpRequest({ method: "GET", url: "https://unpkg.com/[email protected]/dist/vue.global.js", onload: function (response) { addScript(response.responseText); } }); GM_xmlhttpRequest({ method: "GET", url: "https://unpkg.com/[email protected]/dist/index.js", onload: function (response) { addScript(response.responseText); } }); // 以下方法有 CSP 限制 // const naiveScript = document.createElement('script'); // naiveScript.setAttribute("type", "text/javascript"); // naiveScript.text = "https://unpkg.com/[email protected]/dist/index.js"; // document.documentElement.appendChild(naiveScript); }) } /** * 初始化配置面板,插入配置面板的 HTML 到 body 中。 * * @returns {Promise} * @private */ _initPanel() { const that = this return new Promise(resolve => { const panelRoot = document.createElement('div'); panelRoot.innerHTML = that.panelHTML; document.body.appendChild(panelRoot); resolve() }) } /** * 初始化 Vue 实例,挂载到配置面板的 HTML 元素上。 * * @private */ _initVue() { const app = Vue.createApp(this.appConfig); app.use(naive) app.mount(`#${this.appID}`); } /** * 初始化配置面板大小与位置 * * @private */ _initConfigPanelSizeAndPosition() { const panel = document.getElementById(this.appID) // 获取存储的大小 const size = GM_getValue(SystemConfig.GMStorageKey.ConfigPanel.Size, {}) if (size && size.width && !isNaN(size.width)) { panel.style.width = size.width + 'px'; } // 获取存储的位置 const position = GM_getValue(SystemConfig.GMStorageKey.ConfigPanel.Position, {}) if (position && position.left && position.top && !isNaN(position.left) && !isNaN(position.top)) { const {left, top} = position const {refineLeft, refineTop} = this.refinePosition(left, top, panel.offsetWidth, panel.offsetHeight) panel.style.left = refineLeft + 'px'; panel.style.top = refineTop + 'px'; } // 如果面板任何一边超出屏幕,则重置位置 // const rect = panel.getBoundingClientRect() // const leftTop = { // x: rect.left, // y: rect.top // } // const rightBottom = { // x: rect.left + rect.width, // y: rect.top + rect.height // } // const screenWidth = window.innerWidth; // const screenHeight = window.innerHeight; // if (leftTop.x < 0 || leftTop.y < 0 || rightBottom.x > screenWidth || rightBottom.y > screenHeight) { // panel.style.left = '50px'; // panel.style.top = '50px'; // } } /** * 初始化配置面板事件监控,包括面板拖动、面板大小变化等事件。 * * @private */ _initConfigPanelEventMonitor() { const that = this const panel = document.getElementById(this.appID) const draggableArea = document.getElementById(`${this.appID}-DraggableArea`) // 监听面板宽度变化 const resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { if (entry.contentRect.width) { GM_setValue(SystemConfig.GMStorageKey.ConfigPanel.Size, { width: entry.contentRect.width, }) } } }); resizeObserver.observe(panel); // 监听面板位置 draggableArea.addEventListener('mousedown', function (e) { const offsetX = e.clientX - draggableArea.getBoundingClientRect().left; const offsetY = e.clientY - draggableArea.getBoundingClientRect().top; function mouseMoveHandler(e) { const left = e.clientX - offsetX; const top = e.clientY - offsetY; const { refineLeft, refineTop } = that.refinePosition(left, top, panel.offsetWidth, panel.offsetHeight); panel.style.left = refineLeft + 'px'; panel.style.top = refineTop + 'px'; GM_setValue(SystemConfig.GMStorageKey.ConfigPanel.Position, { left: refineLeft, top: refineTop, }) } document.addEventListener('mousemove', mouseMoveHandler); document.addEventListener('mouseup', function () { document.removeEventListener('mousemove', mouseMoveHandler); }); }); } /** * 限制面板位置,使其任意一部分都不超出屏幕 * * @param left 面板左上角 x 坐标 * @param top 面板左上角 y 坐标 * @param width 面板宽度 * @param height 面板高度 * @returns {{refineLeft: number, refineTop: number}} 返回修正后的坐标 */ refinePosition(left, top, width, height) { const screenWidth = window.innerWidth; const screenHeight = window.innerHeight; return { refineLeft: Math.min(Math.max(0, left), screenWidth - width), refineTop: Math.min(Math.max(0, top), screenHeight - height), } } /** * 初始化菜单命令,用于在 Tampermonkey 的菜单中添加一个配置面板的命令。 * * @private */ _initMenuCommand() { let that = this GM_registerMenuCommand("配置面板", () => { that.show() }) } /** * 显示配置面板 */ show() { document.getElementById(this.appID).style.visibility = 'visible'; } /** * 隐藏配置面板 */ hide() { document.getElementById(this.appID).style.visibility = 'hidden'; } /** * 更新配置,由 Vue 组件调用来更新配置并重新渲染时间 * @param config */ updateConfig(config) { this.userConfig.update({timeRender: config}) this.timeRendererService.reRender() } } class Main { static ComponentsConfig = [UserConfig, StyleService, MessageService, MonitorService, TimeRendererService, ConfigPanelService,] constructor() { for (let componentClazz of Main.ComponentsConfig) { const instance = new componentClazz(); this[componentClazz.name] = instance ComponentLocator.register(componentClazz, instance) } } /** * 获取依赖关系图 * @returns {[]} 依赖关系图,例如 [{node:'ComponentClass0', dependencies:['ComponentClass2', 'ComponentClass3']}, ...] * @private */ _getDependencyGraph() { const dependencyGraph = [] for (let componentClazz of Main.ComponentsConfig) { const dependencies = this[componentClazz.name].dependencies.map(dependency => dependency.clazz.name) dependencyGraph.push({node: componentClazz.name, dependencies}) } return dependencyGraph } start() { const dependencyGraph = this._getDependencyGraph() const order = Utils.dependencyAnalysis(dependencyGraph) Logger.debug('初始化顺序:', order.join(' -> ')) for (let componentName of order) { this[componentName].initDependencies() } for (let componentName of order) { this[componentName].init() } } } const main = new Main(); main.start(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址