-
-
Save PYUDNG/ae2a1a829546a11fd6c00a6425ba7a14 to your computer and use it in GitHub Desktop.
轻小说文库+ v2.28.2 (GreasyFork Bug Report)
This file has been truncated, but you can view the full file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name 轻小说文库+ | |
| // @namespace https://greasyfork.org/users/667968-pyudng | |
| // @version 2.28.2 | |
| // @description 轻小说文库全方位体验改善,涵盖阅读、下载、书架、推荐、书评、账号、页面个性化等各种方面,你能想到的这里都有。没有?欢迎提出你的点子。 | |
| // @author PY-DNG | |
| // @license GPL-3.0-or-later | |
| // @homepageURL https://greasyfork.org/scripts/539514 | |
| // @supportURL https://greasyfork.org/scripts/539514/feedback | |
| // @match http*://*.wenku8.com/* | |
| // @match http*://*.wenku8.net/* | |
| // @match http*://*.wenku8.cc/* | |
| // @require data:application/javascript,window.setImmediate%20%3D%20window.setImmediate%20%7C%7C%20((f%2C%20...args)%20%3D%3E%20window.setTimeout(()%20%3D%3E%20f(args)%2C%200))%3B | |
| // @require https://update.greasyfork.org/scripts/456034/1651347/Basic%20Functions%20%28For%20userscripts%29.js | |
| // @require https://update.greasyfork.org/scripts/471280/1247074/URL%20Encoder.js | |
| // @require https://update.greasyfork.org/scripts/549682/1681117/BBCode%20Parser.js | |
| // @require https://fastly.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js | |
| // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js | |
| // @require https://fastly.jsdelivr.net/npm/ejs@3.1.9/ejs.min.js | |
| // @require https://fastly.jsdelivr.net/npm/jepub@2.1.4/dist/jepub.min.js | |
| // @require https://fastly.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js | |
| // @require https://cdnjs.cloudflare.com/ajax/libs/jspdf/3.0.3/jspdf.umd.min.js | |
| // @require https://unpkg.com/@popperjs/core@2 | |
| // @require https://unpkg.com/tippy.js@6 | |
| // @resource vue-js https://unpkg.com/vue@3.5.13/dist/vue.global.prod.js | |
| // @resource quasar-icon https://fonts.font.im/css?family=Roboto:100,300,400,500,700,900|Material+Icons | |
| // @resource quasar-css https://fastly.jsdelivr.net/npm/quasar@2.15.1/dist/quasar.prod.css | |
| // @resource quasar-js https://fastly.jsdelivr.net/npm/quasar@2.15.1/dist/quasar.umd.prod.js | |
| // @resource vue-js-bak https://fastly.jsdelivr.net/npm/vue@3.5.13/dist/vue.global.min.js | |
| // @resource quasar-icon-bak https://google-fonts.mirrors.sjtug.sjtu.edu.cn/css?family=Roboto:100,300,400,500,700,900|Material+Icons | |
| // @resource quasar-css-bak https://unpkg.com/quasar@2.15.1/dist/quasar.prod.css | |
| // @resource quasar-js-bak https://unpkg.com/quasar@2.15.1/dist/quasar.umd.prod.js | |
| // @connect wenku8.com | |
| // @connect wenku8.net | |
| // @connect wenku8.cc | |
| // @connect 777743.xyz | |
| // @icon https://www.wenku8.cc/favicon.ico | |
| // @grant GM_getResourceText | |
| // @grant GM_registerMenuCommand | |
| // @grant GM_setValue | |
| // @grant GM_getValue | |
| // @grant GM_listValues | |
| // @grant GM_deleteValue | |
| // @grant GM_addValueChangeListener | |
| // @grant GM_removeValueChangeListener | |
| // @grant GM_log | |
| // @grant GM_addElement | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM_setClipboard | |
| // ==/UserScript== | |
| /* eslint-disable no-multi-spaces */ | |
| /* eslint-disable no-return-assign */ | |
| /* global LogLevel DoLog Err Assert $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager queueTask FunctionLoader loadFuncs require isLoaded default_pool */ | |
| /* global $URL, Vue, Quasar, Sortable, confetti, JSZip, jEpub, jspdf, tippy */ | |
| (function __MAIN__() { | |
| 'use strict'; | |
| const CONST = { | |
| // UI用文本常量 | |
| TextAllLang: { | |
| DEFAULT: 'zh-CN', | |
| 'zh-CN': { | |
| ExportDebugInfo: '导出调试信息', | |
| EnableScriptDebugging: '开启调试', | |
| DisableScriptDebugging: '关闭调试', | |
| Announcements: { | |
| Running: `${GM_info.script.name} v${GM_info.script.version} 正在运行` | |
| }, | |
| Unlocker: { | |
| FetchingContent: `[${GM_info.script.name}] 正在获取章节内容...`, | |
| ConstructingPage: `[${GM_info.script.name}] 正在构建页面...`, | |
| FetchingDownloadInfo: `[${GM_info.script.name}] 正在获取下载信息...`, | |
| }, | |
| SidePanel: { | |
| PanelShowHide: '显示/隐藏', | |
| GotoTop: '回到顶部', | |
| GotoBottom: '跳至底部', | |
| Refresh: '刷新页面' | |
| }, | |
| Settings: { | |
| SideButtonLabel: '设置', | |
| DialogTitle: '设置', | |
| NeedsReload: '修改后需要重新加载页面以生效', | |
| OtherPageNeedsReload: '修改后其他页面需要重新加载以生效', | |
| Tabs: { | |
| ModuleSettings: '模块设置', | |
| About: '关于', | |
| AboutTab: '关于脚本', | |
| FAQ: '常见问题', | |
| }, | |
| About: { | |
| Version: `版本: ${ GM_info.script.version }`, | |
| Author: `作者: ${ GM_info.script.author }`, | |
| Homepage: `主页: <a href="${ GM_info.script.homepageURL ?? GM_info.script.homepage }" target="_blank">Greasyfork</a>`, | |
| get TechnicalNote() { return `${ GM_info.script.name } 使用自行编写的模块加载系统驱动,由 ${ Object.keys(functions).length } 个模块共同组成;在UI方面,使用了<a href="cn.vuejs.org" target="_blank">Vue.js</a> 和 <a href="https://quasar.dev/" target="_blank">Quasar</a> 框架,并以 <a href="https://fonts.google.com/icons" target="_blank">Material Symbols & Icons</a> 作为图标库。`; }, | |
| FAQ: [{ | |
| Q: '为什么模块的设置时常消失?', | |
| A: '模块只会在需要它的功能的页面运行,而在其他页面上由于模块不会运行,其设置项也不会在这些不运行的页面上存在。比如,「书评增强」模块的设置只会在书评页面出现。', | |
| }], | |
| }, | |
| }, | |
| Component: { | |
| SelectImage: '选择图片', | |
| PleaseChoose: '请选择', | |
| InputMustBeFloat: '请输入数值!', | |
| PBookSearch: { | |
| Search: '查找', | |
| Placeholder: '搜索或粘贴ID或链接', | |
| PlaceholderItem: '按Enter键搜索', | |
| SelectType: '搜索类型', | |
| ByName: '书名', | |
| ByAuthor: '作者', | |
| ByID: '书籍ID', | |
| ByLink: '书籍链接', | |
| }, | |
| }, | |
| Styling: { | |
| Settings: { | |
| Title: '页面主题', | |
| Enabled: '启用主题功能', | |
| EnabledCaption: '未启用时,使用原版文库界面', | |
| }, | |
| }, | |
| Darkmode: { | |
| Switch2Dark: '切换到深色模式', | |
| Switch2Light: '切换到浅色模式', | |
| FollowEnabledTip: '您已开启深色模式跟随系统,此时手动切换深色模式无作用', | |
| FollowEnabledTipCaption: '您可到设置中关闭深色模式跟随系统,即可手动切换深色模式', | |
| Settings: { | |
| Label: '深色模式', | |
| Enbaled: '启用深色模式', | |
| EnabledCaption: '此项亦可在右下角侧边栏按钮中快速切换', | |
| FollowSystem: '深色模式跟随系统', | |
| FollowSystemCaption: '此项启用后优先级高于上面的手动开关', | |
| SideButton: '侧边栏快捷切换按钮', | |
| SideButtonCaption: '用于手动控制深色模式开关', | |
| }, | |
| }, | |
| Review: { | |
| FloorManager: { | |
| UpdatingFloors: '正在更新楼层...', | |
| FloorUpdated: '楼层已更新', | |
| FloorUpdatedCaption: '发现 {Updated} 条新内容', | |
| FloorUpdateError: '楼层更新时发生错误', | |
| FloorUpdateErrorCaption: '请检查网络是否通畅,必要时可导出调试信息反馈给开发者', | |
| }, | |
| Cite: { | |
| Cite: '引用', | |
| CiteAltPrefix: '或者,', | |
| CiteAlt: { | |
| NumberOnly: '仅引用楼号', | |
| FullContent: '引用完整内容', | |
| }, | |
| }, | |
| UBBEditor: { | |
| InsertImage: { | |
| InputUrl: '请输入图片链接:', | |
| Title: '插入图片', | |
| Ok: '完成', | |
| Cancel: '取消', | |
| UrlFormatTip: '图片链接应该以 "http://" 或 "https://" 开头,以".jpg" 或 ".png" 等图片文件扩展名结尾', | |
| }, | |
| InsertUrl: { | |
| InputUrl: '请输入链接:', | |
| Title: '插入链接', | |
| Ok: '完成', | |
| Cancel: '取消', | |
| UrlFormatTip: '链接应该以 "http://" 或 "https://" 开头', | |
| }, | |
| }, | |
| ReplyInPage: { | |
| NoEmptyContent: '已成功发送空气', | |
| NoEmptyContentCaption: '不可发送空白内容', | |
| SendingReply: '正在发送评论...', | |
| ReplySent: '已提交评论', | |
| SentStatusDetails: '查看详情', | |
| DetailsOk: '已阅', | |
| }, | |
| Downloader: { | |
| Title: '书评下载器', | |
| ReviewInfo: '书评信息', | |
| ReviewTitle: '标题', | |
| ReviewPages: '总页数', | |
| ReviewID: '书评ID', | |
| DownloadOptions: '下载选项', | |
| Format: '下载为', | |
| Formats: [/*{ | |
| label: 'PDF', | |
| value: 'pdf' | |
| }, { | |
| label: 'Epub', | |
| value: 'epub' | |
| }*/, { | |
| label: '文库代码', | |
| value: 'bbcode' | |
| }, { | |
| label: '文本文档', | |
| value: 'txt' | |
| }, { | |
| label: '网页HTML', | |
| value: 'html' | |
| }], | |
| Download: '下载', | |
| Cancel: '取消', | |
| SideButton: '保存书评', | |
| ProgressPlaceholder: '下载进度将会在此显示', | |
| Progress: { | |
| RootLabel: '书评下载任务', | |
| PagesLabel: '获取页面', | |
| PagesCaption: '共 {Total} 个页面,已获取 {Finished} 个', | |
| Unknown: '未知', | |
| HTML: { | |
| FetchAssets: '加载评论内嵌资源', | |
| FetchAssetsCaption: '共 {Total} 项,已获取 {Finished} 项', | |
| Unknown: '未知', | |
| }, | |
| BBCode: { | |
| MakeFile: '合成文件', | |
| }, | |
| Epub: { | |
| EpubDescription: '轻小说文库书评', | |
| Notes: `<p>书评源自<a href=${ escJsStr(`${ location.protocol }//${ location.host }/`) }>轻小说文库+</a>,来源链接:<a href={URL_HREF}>{URL_TEXT}</a></p><p>由 <a href=${ escJsStr(GM_info.script.homepageURL ?? GM_info.script.homepage) }>轻小说文库+</a> 下载</p>`, | |
| FetchFloors: '获取楼层图片', | |
| FetchFloorsCaption: '共 {Total} 张图片,已获取 {Finished} 张', | |
| GenerateEpub: '生成Epub', | |
| Unknown: '未知', | |
| }, | |
| }, | |
| }, | |
| Settings: { | |
| Label: '书评吐嘈增强', | |
| NoContent: '默认仅引用楼号', | |
| NoContentCaption: '[url=yidxxxxx]#1[/url]', | |
| Pangu: '引用隔离', | |
| PanguCaption: '保持引用部分和周围文字之间有且仅有一个空格', | |
| Select: '引用后选中', | |
| SelectCaption: '引用后,选中插入到输入框的、引用的文字部分', | |
| FloorJump: '页面内跳转(楼层)', | |
| FloorJumpCaption: '点击书评中到某一楼层的链接时,若链接楼层就在本页内,直接跳转至楼层,不再重新加载页面', | |
| PageJump: '页面内跳转(页码)', | |
| PageJumpCaption: '点击右下角切换评论页数时,直接在页面内更新到该页,不再重新加载页面', | |
| ReplyInPage: '页面内发送评论', | |
| ReplyInPageCaption: '发送评论后页面内更新,不再刷新页面', | |
| FillBlank: '字数过少时填充空字符', | |
| FillBlankCaption: '防止因评论过短被文库拒绝发送,需开启页面内发送评论功能(请勿灌水、发表无意义评论)', | |
| EditInPage: '页面内编辑评论', | |
| EditInPageCaption: '页面内弹窗编辑,不再打开新标签页', | |
| AutoRefresh: '楼层自动刷新', | |
| get AutoRefreshCaption() { return `每隔${ CONST.Internal.ReviewAutoRefreshInterval / 1000 }s自动刷新页面内评论,并高亮显示新的楼层和被修改过的楼层`; }, | |
| RefreshToLast: '刷新到最后一页', | |
| RefreshToLastCaption: '楼层自动刷新时,总是刷新到书评的最后一页,而不是当前所在页码', | |
| }, | |
| }, | |
| UserRemark: { | |
| RemarkUser: '用户备注', | |
| RemarkDisplay: '用户备注: {Remark}', | |
| RemarkNotSet: '未设置用户备注', | |
| Prompt: { | |
| Title: '为用户设置备注', | |
| Message: '您要为用户 {Name} 设置的备注为:', | |
| Ok: '保存', | |
| Cancel: '取消', | |
| Saved: '已保存' | |
| }, | |
| Settings: { | |
| Label: '用户备注', | |
| Enabled: '启用用户备注功能', | |
| EnabledCaption: '若不启用,则不会在页面中显示相关UI', | |
| } | |
| }, | |
| UserReview: { | |
| CheckUserReviews: '用户书评', | |
| }, | |
| MetaCopy: { | |
| CopyButton: '[复制]', | |
| Copied: '已复制', | |
| }, | |
| BookDetails: { | |
| ShowDetails: '本书数据', | |
| DataNames: { | |
| 'DayHitsCount': '日点击量', | |
| 'TotalHitsCount': '总点击量', | |
| 'PushCount': '推书次数', | |
| 'FavCount': '收藏人数', | |
| }, | |
| Dialog: { | |
| Title: '书籍数据 - {Name}', | |
| Ok: '确定', | |
| Cancel: '复制', | |
| }, | |
| }, | |
| Bookcase: { | |
| Collector: { | |
| FetchingBookcases: '正在调阅书架...', | |
| ArrangingBookcases: '正在整理书架...', | |
| UpdatingBookcase: '正在更新书架...', | |
| SubmitingChange: '正在提交更改...', | |
| RefreshBookcase: '刷新书架内容', | |
| Refreshed: '书架已刷新', | |
| Removed: '已移出书架', | |
| ActionFinished: '已{ActionName}', | |
| NoBooksSelected: '请先选择要操作的书目!', | |
| Dialog: { | |
| ConfirmRemove: { | |
| Message: '确实要将 {Name} 移出书架么?', | |
| Title: '移出书籍', | |
| ok: '是的', | |
| cancel: '还是算了' | |
| }, | |
| }, | |
| }, | |
| Naming: { | |
| DefaultName: '第{ClassID}组书架', | |
| Rename: '重命名书架', | |
| MoveTo: '移到{Name}', | |
| Dialog: { | |
| PromptNewName: { | |
| Message: '请给 {OldName} 取个新名字吧:', | |
| Title: '重命名书架', | |
| Ok: '保存', | |
| Cancel: '取消', | |
| } | |
| }, | |
| }, | |
| AddpageJump: { | |
| GotoBookcase: '前往书架', | |
| }, | |
| }, | |
| ReadLater: { | |
| Add: '添加到稍后再读', | |
| Added: '添加成功', | |
| AddSuccess: '稍后再读 {Name}', | |
| AddDuplicate: '{Name} 已经在稍后再读中了,要不要现在就读一读呢?', | |
| Title: '稍后再读(拖动可排序)', | |
| EmptyListPlaceholder: '添加到稍后再读的书籍会显示在这里', | |
| }, | |
| BlockFolding: { | |
| Fold: '折叠', | |
| UnFold: '展开', | |
| }, | |
| Downloader: { | |
| SideButton: '下载器', | |
| Title: '文库下载器', | |
| Notes: `<p>本书轻小说文库链接:<a href="{URL}">{URL}</a><br>Epub电子书由<a href="${GM_info.script.homepageURL ?? GM_info.script.homepage}">${GM_info.script.name}</a>合成。</p><p>本资源仅供试读,如喜爱本书,请购买正版。</p>`, | |
| Options: { | |
| Format: { | |
| Title: '格式', | |
| txt: 'TXT 分章节', | |
| txtfull: 'TXT 全本', | |
| epub: 'Epub 电子书', | |
| image: '仅插图', | |
| }, | |
| Encoding: { | |
| Title: '编码', | |
| Caption: '仅对txt类型生效', | |
| gbk: 'GBK', | |
| utf8: 'UTF-8' | |
| }, | |
| Filename: '文件名', | |
| }, | |
| UI: { | |
| DownloadButton: '下载', | |
| Author: '作者: ', | |
| LastUpdate: '最后更新: ', | |
| Tags: '作品Tags: ', | |
| BookStatus: '状态: ', | |
| Intro: '内容简介: ', | |
| ContentSelectorTitle: '请选择下载的章节: ', | |
| ContentSelectorRoot: '全选', | |
| NoContentSelected: '已勾选的下载章节为空', | |
| Progress: { | |
| Global: '当前步骤 ({CurStep}/{Total}): {Name}', | |
| Sub: '当前进度: {CurStep}/{Total}', | |
| Ready: '下载器准备就绪', | |
| Loading: '正在加载书籍信息...', | |
| } | |
| }, | |
| Steps: { | |
| txt: { | |
| NovelContent: '下载章节内容', | |
| EncodeText: '编码文本', | |
| GenerateZIP: '合成ZIP文件', | |
| }, | |
| txtfull: { | |
| NovelContent: '下载章节内容', | |
| EncodeText: '编码文本', | |
| }, | |
| image: { | |
| NovelContent: '下载章节内容', | |
| DownloadImage: '下载图片', | |
| GenerateZIP: '合成ZIP文件', | |
| }, | |
| epub: { | |
| NovelContent: '加载章节内容和图片', | |
| GenerateEpub: '合成Epub文件', | |
| }, | |
| }, | |
| }, | |
| Autovote: { | |
| Add: '添加到自动推书', | |
| Added: '添加成功', | |
| AddSuccess: '将 {Name} 添加到了每日自动推书中', | |
| AddDuplicate: '其实 {Name} 已经在自动推书列表中了', | |
| Configure: '自动推书配置', | |
| VoteStart: '开始自动推书...', | |
| VoteEnd: '自动推书完成', | |
| VoteDetail: '详情', | |
| UI: { | |
| Title: '自动推书配置', | |
| NotLoggedIn: '请先登录再进行自动推书配置', | |
| Votes: '每日推荐票数', | |
| TimeAdded: '添加时间: ', | |
| VotedCount: '累计自动推书: ', | |
| TotalVotes: '已分配的总票数: ', | |
| TotalBooks: '总书籍数量: ', | |
| ConfirmRemove: { | |
| Title: '从自动推书中移除书籍', | |
| Message: '确实要将 {Name} 从自动推书中移除吗?移除后,以前的推书记录也将一同被删除。', | |
| Ok: '确定', | |
| Cancel: '还是算了', | |
| }, | |
| Added: '添加成功', | |
| AddSuccess: '将 {Name} 添加到了每日自动推书中', | |
| AddDuplicate: '{Name} 已在自动推书列表中', | |
| AddbookTitle: '添加推书项', | |
| AddbookBook: '推书书籍', | |
| AddbookVotes: '推书次数', | |
| AddbookOk: '确定', | |
| }, | |
| Settings: { | |
| Title: '自动推书', | |
| Configuration: '自动推书配置', | |
| Configure: '编辑', | |
| Enabled: '启用自动推书', | |
| EnabledCaption: '关闭后将不再每日自动推书、不显示相关UI,但推书配置和记录仍将保留', | |
| } | |
| }, | |
| ReviewCollection: { | |
| CollectionTitle: '书评收藏', | |
| Add: '收藏书评', | |
| Remove: '取消收藏书评', | |
| Added: '已添加到书评收藏', | |
| Removed: '已取消收藏此书评', | |
| HasNewFloors: '<span style="font-weight: bolder;">[有更新]</span>', | |
| Settings: { | |
| Title: '书评收藏', | |
| Enabled: '启用书评收藏', | |
| EnabledCaption: '关闭后,将不再显示相关UI,但收藏的书评仍将保留', | |
| ListPosition: '首页收藏列表放置位置', | |
| ListPositionCaption: '在哪里显示收藏的书评', | |
| ListPositionLeft: '左侧', | |
| ListPositionRight: '右侧', | |
| OpenLastPage: '打开书评最后一页', | |
| OpenLastPageCaption: '从首页的书评收藏列表中打开书评时,直接跳转到书评最后一页', | |
| NewFloorCheckInterval: '检查楼层更新时间间隔(单位:小时)', | |
| NewFloorCheckIntervalCaption: '每过这么长时间就检查一次收藏的书评是否存在新楼层,如果有就在首页提示;设置为0则每次打开新页面时都检查,设置为负数则永不检查(禁用此功能)', | |
| AddOnReply: '回复的同时收藏', | |
| AddOnReplyCaption: '当对书评发表回复时,自动将该书评加入收藏', | |
| AutoRemoveTimeout: '未查看书评自动移除收藏时间(单位:天)', | |
| AutoRemoveTimeoutCaption: '当收藏书评连续这么长时间未打开查看过时,自动将其移除收藏;设置为负数禁用此功能' | |
| }, | |
| }, | |
| Background: { | |
| Settings: { | |
| Title: '自定义背景', | |
| Enabled: '启用自定义背景', | |
| EnabledCaption: '启用后,将改变页面背景,覆盖文库自带白色背景和深色模式的黑色背景', | |
| Type: '背景类型', | |
| Types: [{ | |
| label: '本地图片', | |
| value: 'local' | |
| }, { | |
| label: '网络图片', | |
| value: 'url' | |
| }, { | |
| label: '纯色', | |
| value: 'color' | |
| }], | |
| ImageUrl: '网络图片链接', | |
| Image: '本地图片', | |
| ImageFit: '图片缩放与裁剪', | |
| ImageFitOptions: [{ | |
| label: '放大图片到宽或者高的其中任何一条边能够填满屏幕,剩余不能填满屏幕的部分将用底色填充(底色取决于浏览器),不改变图片宽高比例', | |
| brief: '包含在页面内', | |
| value: 'contain', | |
| }, { | |
| label: '放大图片到能完全覆盖整个页面的最小尺寸,溢出屏幕的部分将被裁剪,不改变图片宽高比例', | |
| brief: '覆盖整个页面', | |
| value: 'cover', | |
| }, { | |
| label: '缩放图片到完全适合网页页面大小,必要时改变图片的宽高比(允许将图片压扁或拉长)', | |
| brief: '缩放到页面尺寸', | |
| value: 'fill', | |
| }, { | |
| label: '保持图片自身原始大小与宽高比,无论是否适合页面', | |
| brief: '保持原始尺寸', | |
| value: 'none', | |
| }], | |
| MaskOpacity: '图片遮罩层不透明度', | |
| MaskBlur: '对图片遮罩层启用高斯模糊', | |
| Color: '颜色', | |
| }, | |
| }, | |
| OpenLastPage: { | |
| OpenLastPageButton: '[打开尾页]', | |
| }, | |
| Blocking: { | |
| BlockUser: '屏蔽用户', | |
| UnBlockUser: '解除屏蔽', | |
| UserBlocked: '该用户已被屏蔽', | |
| BlockBook: '屏蔽本书', | |
| UnBlockBook: '解除屏蔽', | |
| BlockedBook: '已屏蔽 {Name}', | |
| UnBlockedBook: '已解除屏蔽 {Name}', | |
| BookBlocked: `[${ GM_info.script.name }]本书已被屏蔽`, | |
| BookBlockedTip: `<div style="text-align:center;">[${ GM_info.script.name }]本书已被屏蔽<br>双击临时显示本书</div>`, | |
| Settings: { | |
| Label: '屏蔽功能', | |
| Enabled: '启用屏蔽功能', | |
| EnabledCaption: '停用后,将同时不再展示屏蔽按钮等界面', | |
| BlockList: '屏蔽列表管理', | |
| BlockListEdit: '编辑', | |
| }, | |
| UI: { | |
| Title: '屏蔽列表管理', | |
| TimeAdded: '加入屏蔽时间: ', | |
| ConfirmRemove: { | |
| Title: '确认移除', | |
| Message: '确定要从屏蔽列表中移除 {Name} 吗?', | |
| Ok: '移除', | |
| Cancel: '不移除', | |
| }, | |
| }, | |
| }, | |
| Reader: { | |
| SideButton: '样式调节', | |
| UI: { | |
| Title: '阅读器样式调节', | |
| Enabled: '启用样式调节', | |
| EnabledCaption: '启用后将覆盖文库自带样式调节', | |
| FontFamily: '字体样式', | |
| FontFamilyCaption: '可自行输入字体名称', | |
| FontSize: '字体大小', | |
| FontSizeSuffix: 'px', | |
| Color: '字体颜色', | |
| ColorCaption: '同时应用于标题和正文', | |
| ScrollSpeed: '滚动速度', | |
| ScrollSpeedCaption: '双击屏幕以滚屏', | |
| FontOptions: [{ | |
| label: '宋体', | |
| value: '宋体', | |
| }, { | |
| label: '新细明体', | |
| value: '新细明体', | |
| }, { | |
| label: '微软雅黑', | |
| value: 'Microsoft Yahei, "微软雅黑"', | |
| }, { | |
| label: '黑体', | |
| value: '黑体', | |
| }, { | |
| label: '楷体', | |
| value: '楷体', | |
| }], | |
| }, | |
| }, | |
| UBBEditor: { | |
| DraftButton: '草稿/历史', | |
| DraftEmpty: '尚无保存的草稿', | |
| DraftEmptyCaption: '在书评编辑框中编写内容后会自动保存草稿,之后就可以点击草稿/历史按钮调用啦', | |
| DraftSwitched: '已读取草稿', | |
| PreviewButton: '预览', | |
| PreviewDialog: { | |
| EmptyTitle: '书评预览', | |
| EmptyContent: '没有内容呢!先写评论再预览吧~', | |
| Ok: '确认', | |
| }, | |
| Editor: { | |
| Title: '书评编辑器', | |
| ReviewTitle: '标题', | |
| Reset: '重置', | |
| Ok: '完成', | |
| DraftSaved: '已保存至草稿({Time})', | |
| History: { | |
| TitlePrefix: { | |
| Review: '书评:', | |
| Book: '小说:', | |
| Reviewedit: '书评编辑:#yid{yid}', | |
| Reviewlist: '书评列表:', | |
| Unknown: '未知页面', | |
| }, | |
| }, | |
| ResetDialog: { | |
| Title: '重置内容', | |
| Message: '放弃所有修改,重置到刚刚打开编辑器时的内容?', | |
| Ok: '重置', | |
| Cancel: '算了', | |
| }, | |
| Buttons: { | |
| Bold: '粗体', | |
| Italic: '斜体', | |
| Color: '字体颜色', | |
| Size: '字体大小', | |
| Underline: '下划线', | |
| Del: '删除线', | |
| Code: '插入代码', | |
| Quote: '插入引用', | |
| Link: '插入链接', | |
| Email: '插入邮箱', | |
| Emoji: '插入表情', | |
| Image: '插入图片', | |
| Align: { | |
| Left: '左对齐', | |
| Center: '居中对齐', | |
| Right: '右对齐', | |
| }, | |
| History: '草稿/历史记录', | |
| }, | |
| Components: { | |
| PDialogFontsize: { | |
| NoNegativeFontSize: '字体大小应为正整数', | |
| PreviewText: '您可以在下面调节字体大小,这行字的大小会跟着改变', | |
| Ok: '确认', | |
| Cancel: '取消', | |
| }, | |
| PDialogFontcolor: { | |
| PreviewHTML: '您可以在下面调节字体颜色,<span style="color: {HEX};">这个部分文字的颜色</span>会跟着改变', | |
| Darkmode: '深色模式预览', | |
| Ok: '确认', | |
| Cancel: '取消', | |
| }, | |
| PDialogEmojiselector: { | |
| Cancel: '取消', | |
| }, | |
| PDialogHistory: { | |
| Title: '草稿/历史记录', | |
| Ok: '加载', | |
| Cancel: '取消', | |
| RemoveTargetNotExist: '该草稿/历史记录条目不存在', | |
| ConfirmRemove: { | |
| Title: '移除草稿/历史记录', | |
| Message: '确定要移除这条草稿/历史记录吗?', | |
| Ok: '移除', | |
| Cancel: '算了', | |
| }, | |
| Removed: '已移除', | |
| Undo: '撤销', | |
| }, | |
| }, | |
| }, | |
| Settings: { | |
| Label: '书评编辑器增强', | |
| MaxDrafts: '草稿/历史记录保存数量上限', | |
| MaxDraftsCaption: '默认30条,超出时会移除最后编辑时间最久远的条目;设为-1可以不限制最大数量,但数据过多会导致脚本变慢', | |
| }, | |
| }, | |
| AccountSwitch: { | |
| UI: { | |
| Title: '切换帐号', | |
| SavedAccount: '已保存的帐号', | |
| AddAccount: '添加帐号', | |
| UnknownCommand: '错误:未知Command值', | |
| Account: '用户名', | |
| Password: '密码', | |
| SavePassword: '记住密码', | |
| Ok: '切换', | |
| LoginError: { | |
| Title: '登录错误', | |
| Message: '登录发生错误:{Info}', | |
| Ok: '确定', | |
| }, | |
| AccountSwitched: { | |
| Message: '已切换帐号', | |
| ShowDetails: '详情', | |
| DetailsOk: '确定', | |
| Reload: '刷新', | |
| AClickReload: '点击刷新页面', | |
| }, | |
| RemoveAccount: { | |
| Title: '移除帐号信息', | |
| Message: '确定要移除帐号 {Username}({Nickname}) 吗?<br>所有帐号信息均仅在浏览器存储,除了用于登录以外,不会离开您的本地计算机', | |
| Ok: '确定移除', | |
| Cancel: '算了', | |
| }, | |
| }, | |
| Config: { | |
| Label: '帐号快捷切换', | |
| AutoReload: '切换帐号后自动刷新页面', | |
| }, | |
| }, | |
| }, | |
| 'zh-TW': { | |
| ExportDebugInfo: '匯出除錯資訊', | |
| EnableScriptDebugging: '開啟除錯', | |
| DisableScriptDebugging: '關閉除錯', | |
| Announcements: { | |
| Running: `${GM_info.script.name} v${GM_info.script.version} 正在運行` | |
| }, | |
| Unlocker: { | |
| FetchingContent: `[${GM_info.script.name}] 正在獲取章節內容...`, | |
| ConstructingPage: `[${GM_info.script.name}] 正在構建頁面...`, | |
| FetchingDownloadInfo: `[${GM_info.script.name}] 正在獲取下載資訊...`, | |
| }, | |
| SidePanel: { | |
| PanelShowHide: '顯示/隱藏', | |
| GotoTop: '回到頂部', | |
| GotoBottom: '跳至底部', | |
| Refresh: '重新整理頁面' | |
| }, | |
| Settings: { | |
| SideButtonLabel: '設定', | |
| DialogTitle: '設定', | |
| NeedsReload: '修改後需要重新載入頁面以生效', | |
| OtherPageNeedsReload: '修改後其他頁面需要重新載入以生效', | |
| Tabs: { | |
| ModuleSettings: '模組設定', | |
| About: '關於', | |
| AboutTab: '關於腳本', | |
| FAQ: '常見問題', | |
| }, | |
| About: { | |
| Version: `版本: ${ GM_info.script.version }`, | |
| Author: `作者: ${ GM_info.script.author }`, | |
| Homepage: `主頁: <a href="${ GM_info.script.homepageURL ?? GM_info.script.homepage }" target="_blank">Greasyfork</a>`, | |
| get TechnicalNote() { return `${ GM_info.script.name } 使用自行編寫的模組載入系統驅動,由 ${ Object.keys(functions).length } 個模組共同組成;在UI方面,使用了<a href="cn.vuejs.org" target="_blank">Vue.js</a> 和 <a href="https://quasar.dev/" target="_blank">Quasar</a> 框架,並以 <a href="https://fonts.google.com/icons" target="_blank">Material Symbols & Icons</a> 作為圖示庫。`; }, | |
| FAQ: [{ | |
| Q: '為什麼模組的設定時常消失?', | |
| A: '模組只會在需要它的功能的頁面運行,而在其他頁面上由於模組不會運行,其設定項也不會在這些不運行的頁面上存在。比如,「書評增強」模組的設定只會在書評頁面出現。', | |
| }], | |
| }, | |
| }, | |
| Component: { | |
| SelectImage: '選擇圖片', | |
| PleaseChoose: '請選擇', | |
| InputMustBeFloat: '請輸入數值!', | |
| PBookSearch: { | |
| Search: '搜尋', | |
| Placeholder: '搜尋或貼上ID或連結', | |
| PlaceholderItem: '按Enter鍵搜尋', | |
| SelectType: '搜尋類型', | |
| ByName: '書名', | |
| ByAuthor: '作者', | |
| ByID: '書籍ID', | |
| ByLink: '書籍連結', | |
| }, | |
| }, | |
| Styling: { | |
| Settings: { | |
| Title: '頁面主題', | |
| Enabled: '啟用主題功能', | |
| EnabledCaption: '未啟用時,使用原版文庫介面', | |
| }, | |
| }, | |
| Darkmode: { | |
| Switch2Dark: '切換到深色模式', | |
| Switch2Light: '切換到淺色模式', | |
| FollowEnabledTip: '您已開啟深色模式跟隨系統,此時手動切換深色模式無作用', | |
| FollowEnabledTipCaption: '您可到設定中關閉深色模式跟隨系統,即可手動切換深色模式', | |
| Settings: { | |
| Label: '深色模式', | |
| Enbaled: '啟用深色模式', | |
| EnabledCaption: '此項亦可在右下角側邊欄按鈕中快速切換', | |
| FollowSystem: '深色模式跟隨系統', | |
| FollowSystemCaption: '此項啟用後優先級高於上面的手動開關', | |
| SideButton: '側邊欄快捷切換按鈕', | |
| SideButtonCaption: '用於手動控制深色模式開關', | |
| }, | |
| }, | |
| Review: { | |
| FloorManager: { | |
| UpdatingFloors: '正在更新樓層...', | |
| FloorUpdated: '樓層已更新', | |
| FloorUpdatedCaption: '發現 {Updated} 條新內容', | |
| FloorUpdateError: '樓層更新時發生錯誤', | |
| FloorUpdateErrorCaption: '請檢查網路是否通暢,必要時可匯出除錯資訊回饋給開發者', | |
| }, | |
| Cite: { | |
| Cite: '引用', | |
| CiteAltPrefix: '或者,', | |
| CiteAlt: { | |
| NumberOnly: '僅引用樓號', | |
| FullContent: '引用完整內容', | |
| }, | |
| }, | |
| UBBEditor: { | |
| InsertImage: { | |
| InputUrl: '請輸入圖片連結:', | |
| Title: '插入圖片', | |
| Ok: '完成', | |
| Cancel: '取消', | |
| UrlFormatTip: '圖片連結應該以 "http://" 或 "https://" 開頭,以".jpg" 或 ".png" 等圖片檔案副檔名結尾', | |
| }, | |
| InsertUrl: { | |
| InputUrl: '請輸入連結:', | |
| Title: '插入連結', | |
| Ok: '完成', | |
| Cancel: '取消', | |
| UrlFormatTip: '連結應該以 "http://" 或 "https://" 開頭', | |
| }, | |
| }, | |
| ReplyInPage: { | |
| NoEmptyContent: '已成功傳送空氣', | |
| NoEmptyContentCaption: '不可傳送空白內容', | |
| SendingReply: '正在傳送評論...', | |
| ReplySent: '已提交評論', | |
| SentStatusDetails: '查看詳情', | |
| DetailsOk: '已閱', | |
| }, | |
| Downloader: { | |
| Title: '書評下載器', | |
| ReviewInfo: '書評資訊', | |
| ReviewTitle: '標題', | |
| ReviewPages: '總頁數', | |
| ReviewID: '書評ID', | |
| DownloadOptions: '下載選項', | |
| Format: '下載為', | |
| Formats: [/*{ | |
| label: 'PDF', | |
| value: 'pdf' | |
| }, { | |
| label: 'Epub', | |
| value: 'epub' | |
| }*/, { | |
| label: '文庫代碼', | |
| value: 'bbcode' | |
| }, { | |
| label: '文本文檔', | |
| value: 'txt' | |
| }, { | |
| label: '網頁HTML', | |
| value: 'html' | |
| }], | |
| Download: '下載', | |
| Cancel: '取消', | |
| SideButton: '保存書評', | |
| ProgressPlaceholder: '下載進度將會在此顯示', | |
| Progress: { | |
| RootLabel: '書評下載任務', | |
| PagesLabel: '獲取頁面', | |
| PagesCaption: '共 {Total} 個頁面,已獲取 {Finished} 個', | |
| Unknown: '未知', | |
| HTML: { | |
| FetchAssets: '載入評論內嵌資源', | |
| FetchAssetsCaption: '共 {Total} 項,已獲取 {Finished} 項', | |
| Unknown: '未知', | |
| }, | |
| BBCode: { | |
| MakeFile: '合成檔案', | |
| }, | |
| Epub: { | |
| EpubDescription: '輕小說文庫書評', | |
| Notes: `<p>書評源自<a href=${ escJsStr(`${ location.protocol }//${ location.host }/`) }>輕小說文庫+</a>,來源連結:<a href={URL_HREF}>{URL_TEXT}</a></p><p>由 <a href=${ escJsStr(GM_info.script.homepageURL ?? GM_info.script.homepage) }>輕小說文庫+</a> 下載</p>`, | |
| FetchFloors: '獲取樓層圖片', | |
| FetchFloorsCaption: '共 {Total} 張圖片,已獲取 {Finished} 張', | |
| GenerateEpub: '生成Epub', | |
| Unknown: '未知', | |
| }, | |
| }, | |
| }, | |
| Settings: { | |
| Label: '書評吐嘈增強', | |
| NoContent: '默認僅引用樓號', | |
| NoContentCaption: '[url=yidxxxxx]#1[/url]', | |
| Pangu: '引用隔離', | |
| PanguCaption: '保持引用部分和周圍文字之間有且僅有一個空格', | |
| Select: '引用後選中', | |
| SelectCaption: '引用後,選中插入到輸入框的、引用的文字部分', | |
| FloorJump: '頁面內跳轉(樓層)', | |
| FloorJumpCaption: '點擊書評中到某一樓層的連結時,若連結樓層就在本頁內,直接跳轉至樓層,不再重新載入頁面', | |
| PageJump: '頁面內跳轉(頁碼)', | |
| PageJumpCaption: '點擊右下角切換評論頁數時,直接在頁面內更新到該頁,不再重新載入頁面', | |
| ReplyInPage: '頁面內傳送評論', | |
| ReplyInPageCaption: '傳送評論後頁面內更新,不再重新整理頁面', | |
| FillBlank: '字數過少時填充空字符', | |
| FillBlankCaption: '防止因評論過短被文庫拒絕發送,需開啟頁面內發送評論功能(請勿灌水、發表無意義評論)', | |
| EditInPage: '頁面內編輯評論', | |
| EditInPageCaption: '頁面內彈窗編輯,不再開啟新標籤頁', | |
| AutoRefresh: '樓層自動重新整理', | |
| get AutoRefreshCaption() { return `每隔${ CONST.Internal.ReviewAutoRefreshInterval / 1000 }s自動重新整理頁面內評論,並高亮顯示新的樓層和被修改過的樓層`; }, | |
| RefreshToLast: '重新整理到最後一頁', | |
| RefreshToLastCaption: '樓層自動重新整理時,總是重新整理到書評的最後一頁,而不是當前所在頁碼', | |
| }, | |
| }, | |
| UserRemark: { | |
| RemarkUser: '使用者備註', | |
| RemarkDisplay: '使用者備註: {Remark}', | |
| RemarkNotSet: '未設定使用者備註', | |
| Prompt: { | |
| Title: '為使用者設定備註', | |
| Message: '您要為使用者 {Name} 設定的備註為:', | |
| Ok: '儲存', | |
| Cancel: '取消', | |
| Saved: '已儲存' | |
| }, | |
| Settings: { | |
| Label: '使用者備註', | |
| Enabled: '啟用使用者備註功能', | |
| EnabledCaption: '若不啟用,則不會在頁面中顯示相關UI', | |
| } | |
| }, | |
| UserReview: { | |
| CheckUserReviews: '使用者書評', | |
| }, | |
| MetaCopy: { | |
| CopyButton: '[複製]', | |
| Copied: '已複製', | |
| }, | |
| Bookcase: { | |
| Collector: { | |
| FetchingBookcases: '正在調閱書架...', | |
| ArrangingBookcases: '正在整理書架...', | |
| UpdatingBookcase: '正在更新書架...', | |
| SubmitingChange: '正在提交變更...', | |
| RefreshBookcase: '重新整理書架內容', | |
| Refreshed: '書架已重新整理', | |
| Removed: '已移出書架', | |
| ActionFinished: '已{ActionName}', | |
| NoBooksSelected: '請先選擇要操作的書目!', | |
| Dialog: { | |
| ConfirmRemove: { | |
| Message: '確實要將 {Name} 移出書架麼?', | |
| Title: '移出書籍', | |
| ok: '是的', | |
| cancel: '還是算了' | |
| }, | |
| }, | |
| }, | |
| Naming: { | |
| DefaultName: '第{ClassID}組書架', | |
| Rename: '重新命名書架', | |
| MoveTo: '移到{Name}', | |
| Dialog: { | |
| PromptNewName: { | |
| Message: '請給 {OldName} 取個新名字吧:', | |
| Title: '重新命名書架', | |
| Ok: '儲存', | |
| Cancel: '取消', | |
| } | |
| }, | |
| }, | |
| AddpageJump: { | |
| GotoBookcase: '前往書架', | |
| }, | |
| }, | |
| BookDetails: { | |
| ShowDetails: '本書數據', | |
| DataNames: { | |
| 'DayHitsCount': '日點擊量', | |
| 'TotalHitsCount': '總點擊量', | |
| 'PushCount': '推書次數', | |
| 'FavCount': '收藏人數', | |
| }, | |
| Dialog: { | |
| Title: '書籍數據 - {Name}', | |
| Ok: '確定', | |
| Cancel: '複製', | |
| }, | |
| }, | |
| ReadLater: { | |
| Add: '新增到稍後再讀', | |
| Added: '新增成功', | |
| AddSuccess: '稍後再讀 {Name}', | |
| AddDuplicate: '{Name} 已經在稍後再讀中了,要不要現在就讀一讀呢?', | |
| Title: '稍後再讀(拖動可排序)', | |
| EmptyListPlaceholder: '新增到稍後再讀的書籍會顯示在這裡', | |
| }, | |
| BlockFolding: { | |
| Fold: '摺疊', | |
| UnFold: '展開', | |
| }, | |
| Downloader: { | |
| SideButton: '下載器', | |
| Title: '文庫下載器', | |
| Notes: `<p>本書輕小說文庫連結:<a href="{URL}">{URL}</a><br>Epub電子書由<a href="${GM_info.script.homepageURL ?? GM_info.script.homepage}">${GM_info.script.name}</a>合成。</p><p>本資源僅供試讀,如喜愛本書,請購買正版。</p>`, | |
| Options: { | |
| Format: { | |
| Title: '格式', | |
| txt: 'TXT 分章節', | |
| txtfull: 'TXT 全本', | |
| epub: 'Epub 電子書', | |
| image: '僅插圖', | |
| }, | |
| Encoding: { | |
| Title: '編碼', | |
| Caption: '僅對txt類型生效', | |
| gbk: 'GBK', | |
| utf8: 'UTF-8' | |
| }, | |
| Filename: '檔案名稱', | |
| }, | |
| UI: { | |
| DownloadButton: '下載', | |
| Author: '作者: ', | |
| LastUpdate: '最後更新: ', | |
| Tags: '作品Tags: ', | |
| BookStatus: '狀態: ', | |
| Intro: '內容簡介: ', | |
| ContentSelectorTitle: '請選擇下載的章節: ', | |
| ContentSelectorRoot: '全選', | |
| NoContentSelected: '已勾選的下載章節為空', | |
| Progress: { | |
| Global: '目前步驟 ({CurStep}/{Total}): {Name}', | |
| Sub: '目前進度: {CurStep}/{Total}', | |
| Ready: '下載器準備就緒', | |
| Loading: '正在載入書籍資訊...', | |
| } | |
| }, | |
| Steps: { | |
| txt: { | |
| NovelContent: '下載章節內容', | |
| EncodeText: '編碼文字', | |
| GenerateZIP: '合成ZIP檔案', | |
| }, | |
| txtfull: { | |
| NovelContent: '下載章節內容', | |
| EncodeText: '編碼文字', | |
| }, | |
| image: { | |
| NovelContent: '下載章節內容', | |
| DownloadImage: '下載圖片', | |
| GenerateZIP: '合成ZIP檔案', | |
| }, | |
| epub: { | |
| NovelContent: '載入章節內容和圖片', | |
| GenerateEpub: '合成Epub檔案', | |
| }, | |
| }, | |
| }, | |
| Autovote: { | |
| Add: '新增到自動推書', | |
| Added: '新增成功', | |
| AddSuccess: '將 {Name} 新增到了每日自動推書中', | |
| AddDuplicate: '其實 {Name} 已經在自動推書列表中了', | |
| Configure: '自動推書設定', | |
| VoteStart: '開始自動推書...', | |
| VoteEnd: '自動推書完成', | |
| VoteDetail: '詳情', | |
| UI: { | |
| Title: '自動推書設定', | |
| NotLoggedIn: '請先登入再進行自動推書設定', | |
| Votes: '每日推薦票數', | |
| TimeAdded: '新增時間: ', | |
| VotedCount: '累計自動推書: ', | |
| TotalVotes: '已分配的總票數: ', | |
| TotalBooks: '總書籍數量: ', | |
| ConfirmRemove: { | |
| Title: '從自動推書中移除書籍', | |
| Message: '確實要將 {Name} 從自動推書中移除嗎?移除後,以前的推書記錄也將一同被刪除。', | |
| Ok: '確定', | |
| Cancel: '還是算了', | |
| }, | |
| Added: '新增成功', | |
| AddSuccess: '將 {Name} 新增到了每日自動推書中', | |
| AddDuplicate: '{Name} 已在自動推書列表中', | |
| AddbookTitle: '新增推書項', | |
| AddbookBook: '推書書籍', | |
| AddbookVotes: '推書次數', | |
| AddbookOk: '確定', | |
| }, | |
| Settings: { | |
| Title: '自動推書', | |
| Configuration: '自動推書設定', | |
| Configure: '編輯', | |
| Enabled: '啟用自動推書', | |
| EnabledCaption: '關閉後將不再每日自動推書、不顯示相關UI,但推書設定和記錄仍將保留', | |
| } | |
| }, | |
| ReviewCollection: { | |
| CollectionTitle: '書評收藏', | |
| Add: '收藏書評', | |
| Remove: '取消收藏書評', | |
| Added: '已新增到書評收藏', | |
| Removed: '已取消收藏此書評', | |
| HasNewFloors: '<span style="font-weight: bolder;">[有更新]</span>', | |
| Settings: { | |
| Title: '書評收藏', | |
| Enabled: '啟用書評收藏', | |
| EnabledCaption: '關閉後,將不再顯示相關UI,但收藏的書評仍將保留', | |
| ListPosition: '首頁收藏列表放置位置', | |
| ListPositionCaption: '在哪裡顯示收藏的書評', | |
| ListPositionLeft: '左側', | |
| ListPositionRight: '右側', | |
| OpenLastPage: '開啟書評最後一頁', | |
| OpenLastPageCaption: '從首頁的書評收藏列表中開啟書評時,直接跳轉到書評最後一頁', | |
| NewFloorCheckInterval: '檢查樓層更新時間間隔(單位:小時)', | |
| NewFloorCheckIntervalCaption: '每過這麼長時間就檢查一次收藏的書評是否存在新樓層,如果有就在首頁提示;設置為0則每次開啟新頁面時都檢查,設置為負數則永不檢查(停用此功能)', | |
| AddOnReply: '回覆的同時收藏', | |
| AddOnReplyCaption: '當對書評發表回覆時,自動將該書評加入收藏', | |
| AutoRemoveTimeout: '未查看書評自動移除收藏時間(單位:天)', | |
| AutoRemoveTimeoutCaption: '當收藏書評連續這麼長時間未開啟查看過時,自動將其移除收藏;設置為負數停用此功能' | |
| }, | |
| }, | |
| Background: { | |
| Settings: { | |
| Title: '自訂背景', | |
| Enabled: '啟用自訂背景', | |
| EnabledCaption: '啟用後,將改變頁面背景,覆蓋文庫自帶白色背景和深色模式的黑色背景', | |
| Type: '背景類型', | |
| Types: [{ | |
| label: '本機圖片', | |
| value: 'local' | |
| }, { | |
| label: '網路圖片', | |
| value: 'url' | |
| }, { | |
| label: '純色', | |
| value: 'color' | |
| }], | |
| ImageUrl: '網路圖片連結', | |
| Image: '本機圖片', | |
| ImageFit: '圖片縮放與裁剪', | |
| ImageFitOptions: [{ | |
| label: '放大圖片到寬或者高的其中任何一條邊能夠填滿螢幕,剩餘不能填滿螢幕的部分將用底色填充(底色取決於瀏覽器),不改變圖片寬高比例', | |
| brief: '包含在頁面內', | |
| value: 'contain', | |
| }, { | |
| label: '放大圖片到能完全覆蓋整個頁面的最小尺寸,溢出螢幕的部分將被裁剪,不改變圖片寬高比例', | |
| brief: '覆蓋整個頁面', | |
| value: 'cover', | |
| }, { | |
| label: '縮放圖片到完全適合網頁頁面大小,必要時改變圖片的寬高比(允許將圖片壓扁或拉長)', | |
| brief: '縮放到頁面尺寸', | |
| value: 'fill', | |
| }, { | |
| label: '保持圖片自身原始大小與寬高比,無論是否適合頁面', | |
| brief: '保持原始尺寸', | |
| value: 'none', | |
| }], | |
| MaskOpacity: '圖片遮罩層不透明度', | |
| MaskBlur: '對圖片遮罩層啟用高斯模糊', | |
| Color: '顏色', | |
| }, | |
| }, | |
| OpenLastPage: { | |
| OpenLastPageButton: '[開啟尾頁]', | |
| }, | |
| Blocking: { | |
| BlockUser: '封鎖使用者', | |
| UnBlockUser: '解除封鎖', | |
| UserBlocked: '該使用者已被封鎖', | |
| BlockBook: '封鎖本書', | |
| UnBlockBook: '解除封鎖', | |
| BlockedBook: '已封鎖 {Name}', | |
| UnBlockedBook: '已解除封鎖 {Name}', | |
| BookBlocked: `[${ GM_info.script.name }]本書已被封鎖`, | |
| BookBlockedTip: `<div style="text-align:center;">[${ GM_info.script.name }]本書已被封鎖<br>雙擊臨時顯示本書</div>`, | |
| Settings: { | |
| Label: '封鎖功能', | |
| Enabled: '啟用封鎖功能', | |
| EnabledCaption: '停用後,將同時不再展示封鎖按鈕等介面', | |
| BlockList: '封鎖列表管理', | |
| BlockListEdit: '編輯', | |
| }, | |
| UI: { | |
| Title: '封鎖列表管理', | |
| TimeAdded: '加入封鎖時間: ', | |
| ConfirmRemove: { | |
| Title: '確認移除', | |
| Message: '確定要從封鎖列表中移除 {Name} 嗎?', | |
| Ok: '移除', | |
| Cancel: '不移除', | |
| }, | |
| }, | |
| }, | |
| Reader: { | |
| SideButton: '樣式調節', | |
| UI: { | |
| Title: '閱讀器樣式調節', | |
| Enabled: '啟用樣式調節', | |
| EnabledCaption: '啟用後將覆蓋文庫自帶樣式調節', | |
| FontFamily: '字型樣式', | |
| FontFamilyCaption: '可自行輸入字型名稱', | |
| FontSize: '字型大小', | |
| FontSizeSuffix: 'px', | |
| Color: '字型顏色', | |
| ColorCaption: '同時應用於標題和內文', | |
| ScrollSpeed: '滾動速度', | |
| ScrollSpeedCaption: '雙擊螢幕以滾屏', | |
| FontOptions: [{ | |
| label: '宋體', | |
| value: '宋體', | |
| }, { | |
| label: '新細明體', | |
| value: '新細明體', | |
| }, { | |
| label: '微軟正黑體', | |
| value: 'Microsoft JhengHei, "微軟正黑體"', | |
| }, { | |
| label: '黑體', | |
| value: '黑體', | |
| }, { | |
| label: '楷體', | |
| value: '楷體', | |
| }], | |
| }, | |
| }, | |
| UBBEditor: { | |
| DraftButton: '草稿/歷史', | |
| DraftEmpty: '尚無保存的草稿', | |
| DraftEmptyCaption: '在書評編輯框中編寫內容後會自動保存草稿,之後就可以點擊草稿/歷史按鈕調用啦', | |
| DraftSwitched: '已讀取草稿', | |
| PreviewButton: '預覽', | |
| PreviewDialog: { | |
| EmptyTitle: '書評預覽', | |
| EmptyContent: '沒有內容呢!先寫評論再預覽吧~', | |
| Ok: '確認', | |
| }, | |
| Editor: { | |
| Title: '書評編輯器', | |
| ReviewTitle: '標題', | |
| Reset: '重置', | |
| Ok: '完成', | |
| DraftSaved: '已儲存至草稿({Time})', | |
| History: { | |
| TitlePrefix: { | |
| Review: '書評:', | |
| Book: '小說:', | |
| Reviewedit: '書評編輯:#yid{yid}', | |
| Reviewlist: '書評列表:', | |
| Unknown: '未知頁面', | |
| }, | |
| }, | |
| ResetDialog: { | |
| Title: '重置內容', | |
| Message: '放棄所有修改,重置到剛剛打開編輯器時的內容?', | |
| Ok: '重置', | |
| Cancel: '算了', | |
| }, | |
| Buttons: { | |
| Bold: '粗體', | |
| Italic: '斜體', | |
| Color: '字體顏色', | |
| Size: '字體大小', | |
| Underline: '底線', | |
| Del: '刪除線', | |
| Code: '插入程式碼', | |
| Quote: '插入引用', | |
| Link: '插入連結', | |
| Email: '插入信箱', | |
| Emoji: '插入表情', | |
| Image: '插入圖片', | |
| Align: { | |
| Left: '靠左對齊', | |
| Center: '置中對齊', | |
| Right: '靠右對齊', | |
| }, | |
| History: '草稿/歷史記錄', | |
| }, | |
| Components: { | |
| PDialogFontsize: { | |
| NoNegativeFontSize: '字體大小應為正整數', | |
| PreviewText: '您可以在下面調節字體大小,這行字的大小會跟著改變', | |
| Ok: '確認', | |
| Cancel: '取消', | |
| }, | |
| PDialogFontcolor: { | |
| PreviewHTML: '您可以在下面調節字體顏色,<span style="color: {HEX};">這個部分文字的顏色</span>會跟著改變', | |
| Darkmode: '深色模式預覽', | |
| Ok: '確認', | |
| Cancel: '取消', | |
| }, | |
| PDialogEmojiselector: { | |
| Cancel: '取消', | |
| }, | |
| PDialogHistory: { | |
| Title: '草稿/歷史記錄', | |
| Ok: '載入', | |
| Cancel: '取消', | |
| RemoveTargetNotExist: '該草稿/歷史記錄條目不存在', | |
| ConfirmRemove: { | |
| Title: '移除草稿/歷史記錄', | |
| Message: '確定要移除這條草稿/歷史記錄嗎?', | |
| Ok: '移除', | |
| Cancel: '算了', | |
| }, | |
| Removed: '已移除', | |
| Undo: '復原', | |
| }, | |
| }, | |
| }, | |
| Settings: { | |
| Label: '書評編輯器增強', | |
| MaxDrafts: '草稿/歷史記錄保存數量上限', | |
| MaxDraftsCaption: '預設30條,超出時會移除最後編輯時間最久遠的條目;設為-1可以不限制最大數量,但資料過多會導致腳本變慢', | |
| }, | |
| }, | |
| AccountSwitch: { | |
| UI: { | |
| Title: '切換帳號', | |
| SavedAccount: '已儲存的帳號', | |
| AddAccount: '新增帳號', | |
| UnknownCommand: '錯誤:未知Command值', | |
| Account: '使用者名稱', | |
| Password: '密碼', | |
| SavePassword: '記住密碼', | |
| Ok: '切換', | |
| LoginError: { | |
| Title: '登入錯誤', | |
| Message: '登入發生錯誤:{Info}', | |
| Ok: '確定', | |
| }, | |
| AccountSwitched: { | |
| Message: '已切換帳號', | |
| ShowDetails: '詳情', | |
| DetailsOk: '確定', | |
| Reload: '重新整理', | |
| AClickReload: '點擊重新整理頁面', | |
| }, | |
| RemoveAccount: { | |
| Title: '移除帳號資訊', | |
| Message: '確定要移除帳號 {Username}({Nickname}) 嗎?<br>所有帳號資訊均僅在瀏覽器儲存,除了用於登入以外,不會離開您的本地電腦', | |
| Ok: '確定移除', | |
| Cancel: '算了', | |
| }, | |
| }, | |
| Config: { | |
| Label: '帳號快捷切換', | |
| AutoReload: '切換帳號後自動刷新頁面', | |
| }, | |
| }, | |
| }, | |
| get ['zh-HK']() { return CONST.TextAllLang['zh-TW']; }, | |
| }, | |
| /** | |
| * @returns {typeof CONST.TextAllLang['zh-CN']>} | |
| */ | |
| get Text() { | |
| const i18n = Object.keys(CONST.TextAllLang).includes(navigator.language) ? navigator.language : CONST.TextAllLang.DEFAULT; | |
| return CONST.TextAllLang[i18n]; | |
| }, | |
| // 文库内部所用常量 | |
| Wenku8: { | |
| /** @typedef {typeof CONST.Wenku8.LanguageCode} LanguageCode */ | |
| LanguageCode: { | |
| Simplified: 0, | |
| Traditional: 1 | |
| } | |
| }, | |
| // 脚本内部配置 | |
| Internal: { | |
| // 脚本自检用常量 | |
| Doctor: { | |
| // 单模块最大存储大小 | |
| MaximumStorageSize: 1024 * 32, | |
| }, | |
| // 用于各类解锁时,取得DOM等模板所用的未锁定书籍的aid | |
| UnlockTemplateAID: 1, | |
| // 最长存储日志页面数量 | |
| DefaultLogMaxPage: 10, | |
| // 最长存储日志条数 | |
| DefaultLogMaxLength: 30, | |
| // 最长存储错误数量 | |
| DefaultErrorMaxLength: 10, | |
| // 板块折叠:消失的板块所对应的折叠记录在连续观察到几次从文档中消失后清除 | |
| RemoveBlockFoldingCount: 10, | |
| // 自动推书:其他标签页存活检测 最长更新时间间隔 | |
| AutovoteActiveTimeout: 10 * 1000, | |
| // 书评楼层自动刷新间隔 | |
| ReviewAutoRefreshInterval: 20 * 1000, | |
| // 默认书评收藏 | |
| BuiltinReviewCollection: [{ | |
| rid: 298520, | |
| name: '[轻小说文库+] 脚本反馈站', | |
| record: { | |
| top: 0, | |
| has_new: true, | |
| last_check: 0, | |
| }, | |
| }, { | |
| rid: 228884, | |
| name: '文库导航姬', | |
| record: { | |
| top: 0, | |
| has_new: true, | |
| last_check: 0, | |
| }, | |
| }, { | |
| rid: 282295, | |
| name: '文库导航 / 中转站', | |
| record: { | |
| top: 0, | |
| has_new: true, | |
| last_check: 0, | |
| }, | |
| }], | |
| // BBCode转换器所用文库表情代码-图片src数据 | |
| WenkuEmojis: [ | |
| ['/:O', '1.gif', '惊讶'], ['/:~', '2.gif', '撇嘴'], ['/:*', '3.gif', '色色'], | |
| ['/:|', '4.gif', '发呆'], ['/8-)', '5.gif', '得意'], ['/:LL', '6.gif', '流泪'], | |
| ['/:$', '7.gif', '害羞'], ['/:X', '8.gif', '闭嘴'], ['/:Z', '9.gif', '睡觉'], | |
| ['/:`(', '10.gif', '大哭'], ['/:-', '11.gif', '尴尬'], ['/:@', '12.gif', '发怒'], | |
| ['/:P', '13.gif', '调皮'], ['/:D', '14.gif', '呲牙'], ['/:)', '15.gif', '微笑'], | |
| ['/:(', '16.gif', '难过'], ['/:+', '17.gif', '耍酷'], ['/:#', '18.gif', '禁言'], | |
| ['/:Q', '19.gif', '抓狂'], ['/:T', '20.gif', '呕吐'] | |
| ], | |
| // 书评图片重试缩放间隔 | |
| ReviewResizeInterval: 500, | |
| // 自适应高度编辑器的最大和最小高度 | |
| EditorHeight: { | |
| Min: 150, | |
| Max: 500, | |
| }, | |
| // 屏蔽书籍临时展示时长 | |
| BlockingBookTempShowTime: 5000, | |
| // 书评收藏楼层更新自动检测最短时间间隔 | |
| ReviewUpdateMinCheckInterval: 10 * 60 * 1000, | |
| // 书评草稿追踪使用过的页面最大数量 | |
| UBBEditorMaximumDraftPage: 10, | |
| }, | |
| }; | |
| const functions = { | |
| utils: { | |
| /** @typedef {Awaited<ReturnType<typeof functions.utils.func>>} utils */ | |
| func() { | |
| /** @type {typeof window} */ | |
| const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; | |
| // 记录开始加载时间 | |
| const load_start = Date.now(); | |
| // 当日志模块加载完毕时,记录日志 | |
| require('logger', true).then(() => { | |
| /** @type {logger} */ | |
| const logger = require('logger'); | |
| logger.log( | |
| logger.LogLevel.Info, | |
| `${GM_info.script.name} v${GM_info.script.version} starting` | |
| ); | |
| }); | |
| // 当基础框架功能集加载完毕时,记录日志 | |
| Promise.all( | |
| ['utils', 'debugging', 'logger', 'dependencies'].map(id => require(id, true)) | |
| ).then(() => { | |
| /** @type {logger} */ | |
| const logger = require('logger'); | |
| logger.log( | |
| logger.LogLevel.Message, | |
| `${GM_info.script.name} v${GM_info.script.version} running` | |
| ); | |
| }); | |
| // 当全部可加载功能加载完毕时,记录日志 | |
| $AEL(default_pool, 'all_load', e => { | |
| /** @type {logger} */ | |
| const logger = require('logger'); | |
| logger.log( | |
| logger.LogLevel.Info, | |
| `[${GM_info.script.name}] all functions loaded, in %c${ Date.now() - load_start }%cms`, | |
| 'color: orange;', | |
| 'color: unset;', | |
| ); | |
| }); | |
| /** | |
| * 获取当前页面的语言:繁体中文/简体中文 | |
| * @returns {number} 文库语言代码,参考 {@link LanguageCode} | |
| */ | |
| function getLanguage() { | |
| if ('currentEncoding' in win) { | |
| return { | |
| 1: CONST.Wenku8.LanguageCode.Traditional, | |
| 2: CONST.Wenku8.LanguageCode.Simplified, | |
| }[win.currentEncoding]; | |
| } else { | |
| return { | |
| 'GBK': CONST.Wenku8.LanguageCode.Simplified, | |
| 'Big5': CONST.Wenku8.LanguageCode.Traditional, | |
| }[document.characterSet]; | |
| } | |
| } | |
| /** | |
| * 将给定html转化为元素,注意这里使用了.innerHTML,因此<script>不会执行 | |
| * 所给html应仅包含一个根层级元素 | |
| * @param {string} html | |
| * @returns {HTMLElement} | |
| */ | |
| function html2elm(html) { | |
| return $$CrE({ | |
| tagName: 'div', | |
| props: { | |
| innerHTML: html, | |
| } | |
| }).firstElementChild; | |
| } | |
| /** | |
| * 向输入框的当前光标位置中插入文本 | |
| * @param {HTMLTextAreaElement | HTMLInputElement} elm - 输入框元素 | |
| * @param {string} text - 待插入的文本 | |
| * @param {string} [pangu=false] - 是否保证插入部分和周围文本之间至少有一个空格 | |
| * @param {string} [select=false] - 是否选中插入部分内容 | |
| */ | |
| function insertText(elm, text, pangu=false, select=false) { | |
| const orig_start = elm.selectionStart; | |
| let before_selection = elm.value.slice(0, elm.selectionStart); | |
| let after_selection = elm.value.slice(elm.selectionEnd); | |
| if (pangu) { | |
| // 当前面有内容时,将前面内容的结尾空格替换为1个 | |
| if (before_selection && !before_selection.endsWith('\n')) { | |
| before_selection = before_selection.replace(/ +$/g, ''); | |
| text = ' ' + text; | |
| } | |
| // 无论后面是否有内容,均将后面内容的开头空格替换为1个 | |
| after_selection = after_selection.replace(/^ +/g, ''); | |
| // 当插入内容以空格或换行符结尾时,不再在后续添加空格 | |
| text = /[ \n]$/g.test(text) ? text : text + ' '; | |
| } | |
| elm.value = before_selection + text + after_selection; | |
| const text_end = orig_start + text.length; | |
| if (select) { | |
| elm.setSelectionRange(orig_start, text_end, 'forward'); | |
| } else { | |
| elm.setSelectionRange(text_end, text_end, 'none'); | |
| } | |
| } | |
| /** @typedef {typeof FunctionLoader._types.oFunc} oFunc */ | |
| /** @typedef {InstanceType<typeof FunctionLoader.FuncPool>} FuncPool */ | |
| /** | |
| * 新建一个FuncPool加载oFuncs,oFuncs以对象格式书写(而非标准的数组格式) | |
| * 返回 { promise, pool }, promise将会在加载完毕时resolve,pool为加载时创建的新FuncPool | |
| * @param {Record<string, Omit<oFunc, 'id'>>} oFuncs | |
| * @param {Record<'GM_getValue' | 'GM_setValue' | 'GM_listValues' | 'GM_deleteValue', function>} [GM_funcs={}] | |
| * @returns {{ promise: Promise, pool: FuncPool }} | |
| */ | |
| function loadFuncInNewPool(oFuncs, GM_funcs={}) { | |
| /** | |
| * @param {InstanceType<typeof FunctionLoader.FuncPool>} pool | |
| * @param {Object} oFuncs | |
| */ | |
| async function loadWithErrorHandling(pool, oFuncs) { | |
| /** @type {debugging} */ | |
| const debugging = await require('debugging', true); | |
| debugging.catchPoolErrors(pool); | |
| // 确保oFuncs一定在下个事件循环及以后加载 | |
| // 防止pool还没return就同步加载完成了 | |
| // 导致外部调用方运行时无法获取pool报错 | |
| return new Promise(resolve => | |
| setTimeout( () => pool.load(oFuncs).then(() => resolve()) ) | |
| ); | |
| } | |
| const pool = new FunctionLoader.FuncPool({ GM_funcs }); | |
| const promise = loadWithErrorHandling(pool, oFuncs); | |
| return { promise, pool }; | |
| } | |
| /** | |
| * 创建存储的默认值层,定义默认值后,读取对应键时若无已设置值则返回默认值 | |
| * 返回带默认值的 GM_getValue 函数 | |
| * @param {Record<string, any>} default_values - 存储默认值对象 | |
| * @param {typeof GM_getValue} orig_get - GM_getValue函数 | |
| */ | |
| function defaultedGet(default_values, orig_get) { | |
| const Empty = Symbol('defaultedGet: no value written'); | |
| default_values = window.structuredClone(default_values); | |
| return GM_getValue; | |
| /** | |
| * 带默认值层的GM_getValue读存储函数,会在存储中未写入值时 | |
| * @param {*} key - 需读取的存储的键 | |
| * @param {*} defaultValue - 本次读取时使用的默认值,本次读取中优先级高于之前定义的默认值对象 | |
| */ | |
| function GM_getValue(key, defaultValue = Empty) { | |
| // 之前设置的默认值对象中,此键的默认值 | |
| const global_default = default_values.hasOwnProperty(key) ? structuredClone(default_values[key]) : null; | |
| // 本次调用中,显式设置的默认值 | |
| const current_default = defaultValue; | |
| // 最终使用的默认值 | |
| const default_val = current_default !== Empty ? current_default : global_default; | |
| // 读取值并返回 | |
| const val = orig_get(key, default_val); | |
| return val; | |
| } | |
| } | |
| /** | |
| * 从诸如"普通会员","禁言會員"这样的文字中确定用户组类型 | |
| * @param {string} text | |
| * @returns { 'user' | 'admin' | 'banned' | 'limited' } | |
| */ | |
| function getUserType(text) { | |
| return ({ | |
| // 简体,繁体(推荐),繁体(备用) | |
| '普通会员': 'user', | |
| '普通會員': 'user', | |
| '喱通会员': 'user', | |
| '系统管理员': 'admin', | |
| '系統管理員': 'admin', | |
| '系统嗷理员': 'admin', | |
| '禁言会员': 'banned', | |
| '禁言會員': 'banned', | |
| '禁言會員': 'banned', | |
| '受限会员': 'limited', | |
| '受限會員': 'limited', | |
| '受限會員': 'limited', | |
| }) [text]; | |
| } | |
| /** | |
| * 从诸如"新手上路","高級會員"这样的文字中确定用户等级 | |
| * @param {string} text | |
| * @returns { 'newbie' | 'normal' | 'intermediate' | 'advanced' | 'golden' | 'elder' } | |
| */ | |
| function getUserLevel(text) { | |
| return ({ | |
| // 简体,繁体(推荐),繁体(备用) | |
| '新手上路': 'newbie', | |
| '新手上路': 'newbie', | |
| '新手上路': 'newbie', | |
| '普通会员': 'normal', | |
| '普通會員': 'normal', | |
| '普通會員': 'normal', | |
| '中级会员': 'intermediate', | |
| '中級會員': 'intermediate', | |
| '中級會員': 'intermediate', | |
| '高级会员': 'advanced', | |
| '高級會員': 'advanced', | |
| '坨级会员': 'advanced', | |
| '金牌会员': 'golden', | |
| '金牌會員': 'golden', | |
| '金牌會員': 'golden', | |
| '本站元老': 'elder', | |
| '本站元老': 'elder', | |
| '本站元老': 'elder', | |
| }) [text]; | |
| } | |
| /** | |
| * 获取当前网页已登录用户ID,如果未登录返回null | |
| * @returns {number | null} | |
| */ | |
| function getUserID() { | |
| const userinfo = getUserInfo(); | |
| const str_id = userinfo?.jieqiUserId?.trim() ?? null; | |
| return str_id ? parseInt(str_id, 10) : null; | |
| } | |
| /** | |
| * 获取当前网页已登录用户名(非昵称),如果未登录返回null | |
| * @returns {string | null} | |
| */ | |
| function getUserName() { | |
| const userinfo = getUserInfo(); | |
| const str_id = userinfo.jieqiUserName?.trim() ?? null; | |
| return str_id ?? null; | |
| } | |
| /** | |
| * 判断网页端是否已登录 | |
| * @returns {boolean} | |
| */ | |
| function isLoggedIn() { | |
| return getUserID() !== null; | |
| } | |
| /** | |
| * 从cookie中获取网页已登录用户信息 | |
| * @returns {Record<string, string> | null} | |
| */ | |
| function getUserInfo() { | |
| /** @type {(str: string, item_sep: string | RegExp, keyval_sep: string) => Object} */ | |
| const parse = (str, item_sep, keyval_sep) => str.split(item_sep).map(c => c.split(keyval_sep)).reduce((obj, item) => ((obj[item.shift()] = item.join(keyval_sep), obj)), {}); | |
| const cookies = parse(document.cookie, /; */, '='); | |
| if (!cookies.jieqiUserInfo) return null; | |
| const userinfo = parse(decodeURIComponent(cookies.jieqiUserInfo), ',', '='); | |
| return userinfo; | |
| } | |
| /** | |
| * 判断两个文库网址是否相同,忽略protocol和host部分 | |
| * @param {string} url1 | |
| * @param {string} url2 | |
| * @returns {boolean} | |
| */ | |
| function isSameUrl(url1, url2) { | |
| const [obj_url1, obj_url2] = [url1, url2].map(url => new URL(url)); | |
| return obj_url1.pathname === obj_url2.pathname && | |
| obj_url1.search === obj_url2.search && | |
| obj_url1.hash === obj_url2.hash; | |
| } | |
| /** | |
| * 将给定的方法包装为排队执行的版本,返回的新方法将在队列中执行,以限制最大并行执行数并添加执行间隔 | |
| * @template {function} F | |
| * @param {F} func | |
| * @param {Object} [options] | |
| * @param {string} [options.queue_id] 并行队列id,相同的id将在同一队列内运行;省略时生成随机id | |
| * @param {number} [options.max=5] 最大并行执行数 | |
| * @param {number} [options.sleep=0] 每两次执行间的等待间隔时长 | |
| * @returns {F} | |
| */ | |
| function toQueued(func, { queue_id=null, max = 5, sleep = 0 } = {}) { | |
| queue_id === null && (queue_id = 'toQueued-' + randstr()); | |
| queueTask[queue_id] = { max, sleep }; | |
| return function queued(...args) { | |
| return queueTask(() => func(...args), queue_id); | |
| }; | |
| } | |
| /** | |
| * 以当前网页的编码将form元素内容或者FormData对象序列化为post表单字符串 | |
| * @param {HTMLFormElement | FormData} form | |
| * @returns {string} | |
| */ | |
| function serializeFormData(form) { | |
| /** @type {FormData} */ | |
| const formdata = Object.prototype.toString.call(form) === '[object FormData]' ? | |
| form : new FormData(form); | |
| return [...formdata.entries()].map(([key, val]) => | |
| `${ encodeURIComponent(key) }=${ encodeURIComponent(val) }`).join('&'); | |
| /** | |
| * 和标准同名方法一致,但是根据当前文档的编码进行 | |
| * @type {typeof window.encodeURIComponent} | |
| */ | |
| function encodeURIComponent(text) { | |
| return Array.from(text).map(char => | |
| /[A-Za-z0-9\-_\.!~\*'\(\)]/.test(char) ? | |
| char : $URL.encode(char) | |
| ).join(''); | |
| } | |
| } | |
| /** | |
| * 在给定字符串头部填0使字符串达到给定长度 | |
| * @param {string} text | |
| * @param {number} len | |
| * @returns {String} | |
| */ | |
| function zfill(text, len) { | |
| return '0'.repeat(Math.max(0, len - text.length)) + text; | |
| } | |
| /** | |
| * Encode text into html text format | |
| * @param {string} text | |
| * @returns {string} | |
| */ | |
| function htmlEncode(text) { | |
| const span = $CrE('div'); | |
| span.innerText = text; | |
| return span.innerHTML; | |
| } | |
| /** | |
| * 随机字符串 | |
| * @param {number} length - 随机字符串长度 | |
| * @param {boolean} cases - 是否包含大写字母 | |
| * @param {string[]} aviod - 需要排除的字符串,在这里的字符串不会作为随机结果返回;通常用于防止随机出重复字符串 | |
| * @returns {string} | |
| */ | |
| function randstr(length=16, cases=true, aviod=[]) { | |
| const all = 'abcdefghijklmnopqrstuvwxyz0123456789' + (cases ? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' : ''); | |
| while (true) { | |
| let str = ''; | |
| for (let i = 0; i < length; i++) { | |
| str += all.charAt(randint(0, all.length-1)); | |
| } | |
| if (!aviod.includes(str)) {return str;}; | |
| } | |
| } | |
| /** | |
| * 随机整数 | |
| * @param {number} min - 最小值(包含) | |
| * @param {number} max - 最大值(包含) | |
| * @returns {number} | |
| */ | |
| function randint(min, max) { | |
| return Math.floor(Math.random() * (max - min + 1)) + min; | |
| } | |
| /** | |
| * 深度比较两个值是否相等(值相等,不要求引用相等) | |
| * 本函数由deepseek编写的代码再编辑而成 | |
| * @param {*} value1 - 第一个值 | |
| * @param {*} value2 - 第二个值 | |
| * @param {boolean} [sorting=true] - 是否考虑顺序(数组元素顺序、对象属性顺序等) | |
| * @returns {boolean} - 如果两个值深度相等则返回true,否则返回false | |
| */ | |
| function deepEqual(value1, value2, sorting = true) { | |
| // 处理基本类型的快速比较 | |
| if (value1 === value2) return true; | |
| // 处理null和undefined | |
| if (value1 == null || value2 == null) { | |
| return value1 === value2; | |
| } | |
| // 处理NaN | |
| if (Number.isNaN(value1) && Number.isNaN(value2)) return true; | |
| // 检查类型是否一致 | |
| if (typeof value1 !== typeof value2) return false; | |
| // 处理基本类型(经过前面的比较,这里肯定不相等) | |
| if (typeof value1 !== "object") return false; | |
| // 处理数组 | |
| if (Array.isArray(value1)) { | |
| if (!Array.isArray(value2)) return false; | |
| if (value1.length !== value2.length) return false; | |
| // 如果考虑顺序,直接按顺序比较 | |
| if (sorting) { | |
| for (let i = 0; i < value1.length; i++) { | |
| if (!deepEqual(value1[i], value2[i], sorting)) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| // 如果不考虑顺序,需要检查每个元素是否在另一个数组中存在 | |
| const arr2Copy = [...value2]; | |
| for (const item1 of value1) { | |
| let found = false; | |
| for (let j = 0; j < arr2Copy.length; j++) { | |
| if (deepEqual(item1, arr2Copy[j], sorting)) { | |
| arr2Copy.splice(j, 1); | |
| found = true; | |
| break; | |
| } | |
| } | |
| if (!found) return false; | |
| } | |
| return true; | |
| } | |
| // 处理对象 | |
| if (typeof value1 === "object" && typeof value2 === "object") { | |
| const keys1 = Object.keys(value1); | |
| const keys2 = Object.keys(value2); | |
| // 检查key数量 | |
| if (keys1.length !== keys2.length) return false; | |
| // 如果考虑顺序,先检查key顺序是否一致 | |
| if (sorting) { | |
| for (let i = 0; i < keys1.length; i++) { | |
| if (keys1[i] !== keys2[i]) return false; | |
| } | |
| } else { | |
| // 如果不考虑顺序,检查key集合是否相同 | |
| const keys1Set = new Set(keys1); | |
| for (const key of keys2) { | |
| if (!keys1Set.has(key)) return false; | |
| } | |
| } | |
| // 递归比较每个属性值 | |
| for (const key of keys1) { | |
| if (!deepEqual(value1[key], value2[key], sorting)) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| // 其他情况(如Date、RegExp等)可以在这里添加特殊处理 | |
| // 目前简单转为字符串比较 | |
| return String(value1) === String(value2); | |
| } | |
| /** | |
| * 获取"YYYY-MM-DD hh:mm:ss"格式的本地日期时间字符串 | |
| * @param {boolean} [datepart=true] - 是否包含日期部分,默认为true | |
| * @param {boolean} [timepart=true] - 是否包含时间部分,默认为true | |
| * @returns {string} | |
| */ | |
| function getTimeText(datepart = true, timepart = true) { | |
| const date = new Date(); | |
| const datetext = `${ date.getFullYear() }-${ zfill((date.getMonth() + 1).toString(), 2) }-${ zfill(date.getDate().toString(), 2) }`; | |
| const timetext = `${ zfill(date.getHours().toString(), 2) }:${ zfill(date.getMinutes().toString(), 2) }:${ zfill(date.getSeconds().toString(), 2) }`; | |
| return (datepart ? datetext : '') + ((datepart && timepart) ? ' ' : '') + (timepart ? timetext : ''); | |
| } | |
| /** | |
| * 调用GM_xmlhttpRequest并按照当前页面字符编码解析返回的文本 | |
| * 传入的detail对象中的onload将会被替换为文本字符解码器,因此自定义的onload将不会被执行 | |
| * @param {*} detail | |
| * @returns {Promise<string>} | |
| */ | |
| function requestText(detail) { | |
| const { promise, resolve } = Promise.withResolvers(); | |
| detail.responseType = 'arraybuffer'; | |
| detail.onload = response => { | |
| const buffer = (response.response); | |
| const decoder = new TextDecoder(document.characterSet); | |
| const text = decoder.decode(buffer); | |
| resolve(text); | |
| }; | |
| GM_xmlhttpRequest(detail); | |
| return promise; | |
| } | |
| /** | |
| * 调用GM_xmlhttpRequest并按照当前页面字符编码解析返回的文本为文档 | |
| * 传入的detail对象中的onload将会被替换为文本字符解码器,因此自定义的onload将不会被执行 | |
| * @param {*} detail | |
| * @returns {Promise<Document>} | |
| */ | |
| async function requestDocument(detail) { | |
| const source = await requestText(detail); | |
| const doc = new DOMParser().parseFromString(source, 'text/html'); | |
| return doc; | |
| } | |
| const requestBlob = toQueued(_requestBlob, { | |
| max: 5, | |
| sleep: 0, | |
| queue_id: 'blob_request' | |
| }); | |
| /** | |
| * 获取指定url的文件为blob | |
| * @param {string} url | |
| * @param {number} [retry=3] - 失败重试次数 | |
| * @returns {Promise<Blob>} | |
| */ | |
| function _requestBlob(url, retry=3) { | |
| const { promise, reject, resolve } = Promise.withResolvers(); | |
| GM_xmlhttpRequest({ | |
| method: 'GET', | |
| url, | |
| responseType: 'blob', | |
| onload(response) { | |
| response.status < 400 ? resolve(response.response) : onerror(response); | |
| }, | |
| onerror, | |
| }); | |
| return promise; | |
| function onerror(err) { | |
| retry-- ? _requestBlob(url, retry).then(resolve).catch(reject) : reject(err); | |
| } | |
| } | |
| /** | |
| * 获取OPFS中指定模块的目录 | |
| * 注意:这里并不使用OPFS的全部命名空间,而是将全部脚本所用存储存放到OPFS:WenkuPlus目录中,为日后网站官方开发预留主要命名空间 | |
| * @param {string} id - 指定模块oFunc.id | |
| */ | |
| async function getModuleDir(id) { | |
| const root = await navigator.storage.getDirectory(); | |
| const script_root = await root.getDirectoryHandle('WenkuPlus', { create: true }); | |
| const dir = await script_root.getDirectoryHandle(id, { create: true }); | |
| return dir; | |
| } | |
| /** | |
| * Async task progress manager \ | |
| * when awaiting async tasks, replace `await task` with `await manager.progress(task)` \ | |
| * suppoerts sub-tasks, just `manager.sub(sub_steps, sub_callback)` | |
| */ | |
| class ProgressManager extends EventTarget { | |
| /** @type {*} */ | |
| info; | |
| /** @type {number} */ | |
| steps; | |
| /** @type {number} */ | |
| finished; | |
| /** @type {'none' | 'sub' | 'self'} */ | |
| error; | |
| /** @type {ProgressManager[]} */ | |
| #children; | |
| /** @type {ProgressManager} */ | |
| #parent; | |
| /** | |
| * This callback is called each time a promise resolves | |
| * @callback progressCallback | |
| * @param {number} resolved_count | |
| * @param {number} total_count | |
| * @param {ProgressManager} manager | |
| */ | |
| /** | |
| * @param {number} [steps=0] - total steps count of the task | |
| * @param {progressCallback} [callback] - callback each time progress updates | |
| * @param {*} [info] - attach any data about this manager if need | |
| */ | |
| constructor(steps=0, info=undefined) { | |
| super(); | |
| this.steps = steps; | |
| this.info = info; | |
| this.finished = 0; | |
| this.error = 'none'; | |
| this.#children = []; | |
| this.#broadcast('progress'); | |
| } | |
| add() { this.steps++; } | |
| /** | |
| * @template {Promise | null} task | |
| * @param {task} [promise] - task to await, null is acceptable if no task to await | |
| * @param {number} [finished] - set this.finished to this value, adds 1 to this.finished if omitted | |
| * @param {boolean} [accept_reject=true] - whether to treat rejected promise as resolved; if true, callback will get error object in arguments; if not, progress function itself rejects | |
| * @returns {Promise<Awaited<task>>} | |
| */ | |
| async progress(promise, finished, accept_reject = true) { | |
| let val; | |
| try { | |
| val = await Promise.resolve(promise); | |
| } catch(err) { | |
| this.newError('self', false); | |
| if (!accept_reject) { | |
| throw err; | |
| } | |
| } | |
| try { | |
| this.finished = (typeof finished === 'number' && finished >= 0) ? finished : this.finished + 1; | |
| this.#broadcast('progress'); | |
| //this.finished === this.steps && this.#parent && this.#parent.progress(); | |
| } finally { | |
| return val; | |
| } | |
| } | |
| /** | |
| * New error occured in manager's scope, update error status | |
| * @param {'none' | 'sub' | 'self'} [error='self'] | |
| * @param {boolean} [callCallback=true] | |
| */ | |
| newError(error = 'self', callCallback = true) { | |
| const error_level = ['none', 'sub', 'self']; | |
| if (error_level.indexOf(error) <= error_level.indexOf(this.error)) { return; } | |
| this.error = error; | |
| this.#parent && this.#parent.newError('sub'); | |
| callCallback && this.#broadcast('error'); | |
| } | |
| /** | |
| * Creates a new ProgressManager as a sub-progress of this | |
| * @param {number} [steps=0] - total steps count of the task | |
| * @param {*} [info] - attach any data about the sub-manager if need | |
| */ | |
| sub(steps, info) { | |
| const manager = new ProgressManager(steps ?? 0, info); | |
| manager.#parent = this; | |
| this.#children.push(manager); | |
| this.#broadcast('sub'); | |
| return manager; | |
| } | |
| /** | |
| * reset this to an empty manager | |
| */ | |
| reset() { | |
| this.steps = 0; | |
| this.finished = 0; | |
| this.#parent = null; | |
| this.#children = []; | |
| this.#broadcast('reset'); | |
| } | |
| #broadcast(evt_name) { | |
| //this.callback(this.finished, this.steps, this); | |
| this.dispatchEvent(new CustomEvent(evt_name, { | |
| detail: { | |
| type: evt_name, | |
| manager: this | |
| } | |
| })); | |
| } | |
| get children() { | |
| return [...this.#children]; | |
| } | |
| get parent() { | |
| return this.#parent; | |
| } | |
| } | |
| return { | |
| // 窗口 | |
| window: win, | |
| // 文库相关 | |
| getLanguage, getUserType, getUserLevel, getUserID, getUserName, isLoggedIn, getUserInfo, isSameUrl, | |
| // 功能相关 | |
| insertText, html2elm, loadFuncInNewPool, defaultedGet, requestText, requestDocument, requestBlob, getModuleDir, | |
| // 管理器 | |
| ProgressManager, | |
| // 算法相关 | |
| toQueued, serializeFormData, zfill, htmlEncode, randstr, randint, deepEqual, getTimeText, | |
| }; | |
| } | |
| }, | |
| debugging: { | |
| desc: 'script error handler and debugging tool', | |
| dependencies: 'logger', | |
| params: ['GM_setValue', 'GM_getValue'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.debugging.func>>} debugging */ | |
| async func(GM_setValue, GM_getValue) { | |
| /** | |
| * @typedef {Object} debugging_storage | |
| * @property {ErrorObject[]} errors - 错误存档 | |
| * @property {number} max_save - 最大错误存档长度 | |
| * @property {number} script_debug - 脚本是否处于调试状态 | |
| */ | |
| /** @type {logger} */ | |
| const logger = require('logger'); | |
| // Automatically record default funcpool load errors | |
| catchPoolErrors(default_pool); | |
| // 调试模式接口 | |
| GM_getValue('script_debug', false) && enableScriptDebugging(); | |
| // Menu commands | |
| // Delay 1s to put menu item into last place in menus list | |
| setTimeout(() => { | |
| GM_registerMenuCommand(CONST.Text.ExportDebugInfo, exportDebugInfo); | |
| toggleScriptDebug('script_debug', false); | |
| /** | |
| * | |
| * @param {boolean} toggle - 是否实际改变脚本调试状态,如果为false,则仅更新/创建菜单项 | |
| * @param {string | number} [menu_id] - 需要更新的现有菜单项的id,不提供则新建菜单项 | |
| * @returns | |
| */ | |
| function toggleScriptDebug(menu_id, toggle=true) { | |
| const script_debug = toggle === GM_getValue('script_debug', false); | |
| let label; | |
| if (script_debug) { | |
| // 已处于调试模式,关闭调试模式,提供开启按钮 | |
| toggle && disableScriptDebugging(); | |
| label = CONST.Text.EnableScriptDebugging; | |
| } else { | |
| // 未处于调试模式,开启调试模式,提供关闭按钮 | |
| toggle && enableScriptDebugging(); | |
| label = CONST.Text.DisableScriptDebugging; | |
| } | |
| const options = {}; | |
| GM_registerMenuCommand(label, () => toggleScriptDebug(menu_id, true), { id: menu_id }); | |
| } | |
| }, 1000); | |
| /** | |
| * @typedef {Object} ErrorDetail | |
| * @property {string} [key] - use key to avoid saving same error multiple times | |
| * @property {string} type | |
| * @property {Error} error | |
| * @property {*} info | |
| */ | |
| /** | |
| * @typedef {Object} ErrorObject | |
| * @property {string} [key] | |
| * @property {string} type | |
| * @property {*} info | |
| * @property {string} message | |
| * @property {string | undefined} stack | |
| * @property {string} url | |
| * @property {boolean} iframe | |
| * @property {number} timestamp | |
| */ | |
| /** | |
| * wrap error details into error object | |
| * @param {ErrorDetail} detail | |
| * @returns {ErrorObject} | |
| */ | |
| function wrapErrorData({type, error, info, key}) { | |
| const data = { | |
| type, info, | |
| message: error.message, | |
| stack: error.stack, | |
| url: location.href, | |
| iframe: window.top !== window, | |
| timestamp: Date.now() | |
| }; | |
| key && (data.key = key); | |
| return data; | |
| } | |
| /** | |
| * Save an error into storage | |
| * @param {ErrorDetail} detail | |
| * @returns {ErrorObject} | |
| */ | |
| function saveError({type, error, info, key}) { | |
| const data = wrapErrorData({type, error, info, key}); | |
| const errors = GM_getValue('errors', []); | |
| if (key && errors.some(error => error.key === key)) { return; } | |
| errors.push(data); | |
| const max_save = GM_getValue('max_save', CONST.Internal.DefaultErrorMaxLength); | |
| while (errors.length > max_save) { errors.shift(); } | |
| GM_setValue('errors', errors); | |
| return data; | |
| } | |
| /** @typedef {InstanceType<typeof FunctionLoader.FuncPool>} FuncPool */ | |
| /** | |
| * Automatically catch and save all errors from FuncPool loaded oFuncs | |
| * @param {FuncPool} pool | |
| */ | |
| function catchPoolErrors(pool) { | |
| pool.catch_errors = true; | |
| pool.addEventListener('error', e => { | |
| const { error, oFunc } = e.detail; | |
| dealLoadError(error, oFunc); | |
| }); | |
| pool.errors.forEach(({error, oFunc}) => dealLoadError(error, oFunc)); | |
| function dealLoadError(error, oFunc) { | |
| saveError({ | |
| type: 'oFunc', | |
| error, | |
| info: { id: oFunc.id }, | |
| //key: `oFunc-${oFunc.id}` | |
| }); | |
| if (GM_getValue('script_debug', false)) { | |
| throw error; | |
| } else { | |
| logger.error(logger.LogLevel.Error, error); | |
| } | |
| }; | |
| } | |
| /** | |
| * @callback ErrorHandler | |
| * @param {Error} err - the error object | |
| * @param {function} func - function tried to run | |
| * @param {any} thisArg - thisArg passed to the function | |
| * @param {any[]} args - thisArg passed to the function | |
| * @returns {boolean} whether to save this error | |
| */ | |
| /** | |
| * Call given function with error handling | |
| * @template {function} F | |
| * @param {F} func | |
| * @param {any} [thisArg] | |
| * @param {any[]} [args] | |
| * @param {ErrorHandler} [handler] - callback when error occurs, defaults to log the error | |
| * @returns {ReturnType<F>} | |
| */ | |
| function callWithErrorHandling(func, thisArg=null, args=[], handler=null) { | |
| try { | |
| return func.apply(thisArg, args); | |
| } catch (err) { | |
| const save = typeof handler === 'function' ? handler(err, func, thisArg, args) : true; | |
| save && saveError({ | |
| type: 'func-error', | |
| error: err, | |
| info: { func/*, thisArg, args*/ } // thisArg and args may contain circular structure | |
| }); | |
| if (GM_getValue('script_debug', false)) { | |
| throw err; | |
| } else { | |
| logger.error(logger.LogLevel.Error, err); | |
| } | |
| } | |
| } | |
| /** | |
| * Export an error to user as a json file | |
| * returns error object | |
| * @param {ErrorDetail} detail | |
| * @returns {ErrorObject} | |
| */ | |
| function exportError({type, error, info, key}) { | |
| const data = wrapErrorData({type, error, info, key}); | |
| download_object(data, `${GM_info.script.name} Error.json`); | |
| return data; | |
| } | |
| /** | |
| * Export all saved errors to user as a json file | |
| */ | |
| function exportAllErrors() { | |
| const errors = GM_getValue('errors', []); | |
| download_object(errors, `${GM_info.script.name} All Errors.json`); | |
| } | |
| function exportDebugInfo() { | |
| const errors = GM_getValue('errors', []); | |
| const logs = logger.pages; | |
| const debug_info = { | |
| errors, logs, | |
| ua: navigator.userAgent, | |
| version: GM_info.script.version, | |
| manager: GM_info.scriptHandler, | |
| manager_version: GM_info.version, | |
| timestamp: Date.now(), | |
| }; | |
| download_object(debug_info, `${GM_info.script.name} Debug Info.json`); | |
| } | |
| /** | |
| * download any jsonable data as file | |
| * @param {*} data - any jsonable data | |
| * @param {string} filename | |
| * @param {string} mimetype | |
| */ | |
| function download_object(data, filename, mimetype='application/json') { | |
| const json = JSON.stringify(data); | |
| const url = URL.createObjectURL(new Blob([json], { type: mimetype })); | |
| dl_browser(url, filename); | |
| setTimeout(() => URL.revokeObjectURL(url)); | |
| } | |
| function enableScriptDebugging() { | |
| // Do not depend on utils (or any other dependencies) while debugging | |
| GM_setValue('script_debug', true); | |
| const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; | |
| win.w8p = { | |
| // 脚本实现的接口 | |
| require, default_pool, | |
| // 脚本@require的接口 | |
| $URL, confetti, Vue, Quasar, Sortable, JSZip, jEpub, | |
| }; | |
| logger.log(logger.LogLevel.Message, `[${GM_info.script.name}]\nScript debugging enabled.\nDebugging interface injected as %cwindow.w8p%c.`, 'color: #6666CC;', ''); | |
| } | |
| function disableScriptDebugging() { | |
| GM_setValue('script_debug', false); | |
| const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; | |
| delete win.w8p; | |
| logger.log(logger.LogLevel.Message, `[${GM_info.script.name}] Script debugging disabled.`); | |
| } | |
| return { | |
| wrapErrorData, saveError, catchPoolErrors, callWithErrorHandling, exportError, exportAllErrors, exportDebugInfo, enableScriptDebugging, | |
| /** @type {ErrorObject[]} */ | |
| get errors() { return GM_getValue('errors', []); }, | |
| /** @type {number} */ | |
| get max_save() { return GM_getValue('max_save', 10); }, | |
| set max_save(val) { GM_setValue('max_save', val); }, | |
| /** @type {boolean} */ | |
| get script_debug() { return GM_getValue('script_debug', false); }, | |
| set script_debug(val) { GM_setValue('script_debug', val); } | |
| }; | |
| } | |
| }, | |
| logger: { | |
| dependencies: 'utils', | |
| params: ['GM_setValue', 'GM_getValue'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.logger.func>>} logger */ | |
| func(GM_setValue, GM_getValue) { | |
| /** | |
| * @typedef {Object} logger_storage | |
| * @property {LogPage[]} pages | |
| * @property {number} [loglevel] - 日志输出级别 | |
| * @property {number} [max_pages] - 最多存储页面数量 | |
| * @property {number} [max_logs] - 每个页面最多存储日志条数 | |
| */ | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| const csl = Object.assign({}, console); | |
| const LogLevel = { | |
| // 仅作调试用途 | |
| Debug: 0, | |
| // 详细运行日志 | |
| Info: 1, | |
| // 运行日志中可能需要关注的部分 | |
| Warn: 2, | |
| // 运行过程中的主要(简略)日志内容 | |
| Message: 2, | |
| // 报错日志 | |
| Error: 3, | |
| }; | |
| /** | |
| * @typedef {Object} LogData | |
| * @property {number} level - 日志级别,仅用于筛选是否在控制台输出,不会改变输出内容和格式 | |
| * @property {keyof typeof csl} funcname - 使用的log函数名,如'log','error'等 | |
| * @property {any} content | |
| * @property {number} timestamp | |
| * @property {string} url | |
| * @property {boolean} iframe | |
| */ | |
| /** | |
| * 代表一个页面上的全部日志 | |
| * @typedef {Object} LogPage | |
| * @property {number} id - 页面id,用 performance.timeOrigin 表示 | |
| * @property {LogData[]} logs | |
| * @property {string} url | |
| * @property {number | null} parent - 若页面在iframe中,为父页面的id;若不在,则为null | |
| */ | |
| /** | |
| * wrap content into standard log data format | |
| * @param {number} level - 日志级别,仅用于筛选是否在控制台输出,不会改变输出内容和格式 | |
| * @param {keyof typeof csl} funcname - 使用的log函数名,如'log','error'等 | |
| * @param {*} content | |
| * @returns {LogData} | |
| */ | |
| function wrapLog(level, funcname, content) { | |
| return { | |
| level, funcname, content, | |
| timestamp: Date.now(), | |
| url: location.href, | |
| iframe: utils.window.top !== utils.window | |
| }; | |
| } | |
| /** | |
| * 获取当前页面的日志对象id | |
| * @returns {number} | |
| */ | |
| function getCurPageID() { | |
| return utils.window.performance.timeOrigin; | |
| } | |
| /** | |
| * 获取当前页面的日志对象 | |
| * @returns {LogPage} | |
| */ | |
| function getCurPage() { | |
| const id = getCurPageID(); | |
| return GM_getValue('pages').find(page => page.id === id); | |
| } | |
| /** | |
| * 获取当前日志输出级别 | |
| * @returns {(typeof LogLevel)[keyof LogLevel]} | |
| */ | |
| function getLoglevel() { | |
| const saved_level = GM_getValue('loglevel', LogLevel.Message); | |
| const script_debug = require('debugging')?.script_debug || false; | |
| return script_debug ? LogLevel.Debug : saved_level; | |
| } | |
| /** | |
| * 设置日志输出级别 | |
| * @param {number} level | |
| */ | |
| function setLoglevel(level) { | |
| GM_setValue('loglevel', level); | |
| } | |
| /** @typedef {number | keyof typeof LogLevel} LogLevelArg */ | |
| /** | |
| * 输出、记录日志,和console.log基本相同 | |
| * 新增参数:第一个参数level日志级别,第二个参数使用的log函数 | |
| * @param {LogLevelArg} level - 日志级别,仅用于筛选是否在控制台输出,不会改变输出内容和格式;可以是数字或者其名称(不区分大小写);参考 {@link LogLevel} | |
| * @param {keyof typeof csl} funcname - 使用的log函数名,如'log','error'等 | |
| * @param {Parameters<typeof console.log>} content - 日志内容 | |
| * @returns {LogData} 当前页面的日志对象 | |
| */ | |
| function _log(level, funcname, ...content) { | |
| if (typeof level === 'string') { | |
| const standard_levelname = level.at(0).toUpperCase() + level.slice(1).toLowerCase(); | |
| if (LogLevel.hasOwnProperty(standard_levelname)) { | |
| level = LogLevel[standard_levelname] ?? level; | |
| } else { | |
| Err(`日志级别应为数字或LogLevel中声明的字符串关键字,而不是 ${ JSON.stringify(level) }`, TypeError); | |
| } | |
| } | |
| // 根据level输出到控制台 | |
| level >= getLoglevel() && csl[funcname](...content); | |
| // 获取页面日志对象 | |
| const pages = GM_getValue('pages', []); | |
| /** @type {LogPage} */ | |
| const page = pages.find(page => page.id === getCurPageID()) ?? { | |
| id: performance.timeOrigin, | |
| logs: [], | |
| parent: utils.window.parent !== utils.window ? utils.window.parent.performance.timeOrigin : null, | |
| url: location.href, | |
| }; | |
| const logs = page.logs; | |
| // 写入页面日志对象,并删除超限旧数据 | |
| logs.push(wrapLog(level, funcname, content)); | |
| logs.splice(0, logs.length - GM_getValue('max_logs', CONST.Internal.DefaultLogMaxLength)); | |
| !pages.includes(page) && pages.push(page); | |
| pages.splice(0, pages.length - GM_getValue('max_pages', CONST.Internal.DefaultLogMaxPage)); | |
| // 保存 | |
| GM_setValue('pages', pages); | |
| return logs; | |
| } | |
| /** | |
| * @param {LogLevelArg} level | |
| * @param {...any} content | |
| */ | |
| function log(level, ...content) { | |
| _log(level, 'log', ...content); | |
| } | |
| /** | |
| * @param {LogLevelArg} level | |
| * @param {...any} content | |
| */ | |
| function error(level, ...content) { | |
| _log(level, 'error', ...content); | |
| } | |
| /** | |
| * @param {LogLevelArg} level | |
| * @param {...any} content | |
| */ | |
| function warn(level, ...content) { | |
| _log(level, 'warn', ...content); | |
| } | |
| return { | |
| // 日志输出等级 | |
| get loglevel() { return getLoglevel(); }, | |
| set loglevel(val) { setLoglevel(val); }, | |
| // 只读日志对象 | |
| get pages() { return GM_getValue('pages'); }, | |
| get logs() { return getCurPage(); }, | |
| // 日志等级表 | |
| LogLevel, | |
| // 记录日志功能函数 | |
| log, error, warn, | |
| }; | |
| } | |
| }, | |
| doctor: { | |
| desc: '用于脚本自检bug并提供修复功能等', | |
| dependencies: ['debugging', 'logger'], | |
| func() { | |
| /** @type {logger} */ | |
| const logger = require('logger'); | |
| /** @type {debugging} */ | |
| const debugging = require('debugging'); | |
| /** | |
| * 代表一条测试项目 | |
| * @typedef {Object} Test | |
| * @property {string} [desc] - 测试的描述 | |
| * @property {() => TestResult} func - 测试函数,输出测试是否通过 | |
| */ | |
| /** | |
| * 代表一条测试结果 | |
| * @typedef {{ result: any, pass: boolean }} TestResult | |
| */ | |
| /** | |
| * @satisfies {Record<string, Test>} | |
| */ | |
| const tests = { | |
| 'lang-struct': { | |
| desc: '检测各语言包文本常量是否结构类型一致', | |
| func() { | |
| const T = CONST.TextAllLang; | |
| const results = Object.keys(T).filter(key => key !== 'DEFAULT').map((key, i, keys) => { | |
| if (i + 1 >= keys.length) { return null; } | |
| const key2 = keys[i+1]; | |
| const { same, diff } = isSameStructure(T[key], T[key2]); | |
| return { | |
| key1: key, key2, same, diff, | |
| }; | |
| }).filter(result => result !== null); | |
| return { | |
| result: results, | |
| pass: results.every(r => r.same), | |
| }; | |
| } | |
| }, | |
| 'huge-storage': { | |
| desc: '检测是否存在过大的存储数据', | |
| func() { | |
| /** | |
| * 代表一条存储项目的大小 | |
| * @typedef {Object} StorageSize | |
| * @property {number} size - 字节数大小 | |
| * @property {boolean} oversize - 是否过大 | |
| * @property {number} ratio - 相对于最大限额的比例 | |
| */ | |
| /** @type {string[]} */ | |
| const keys = GM_listValues(); | |
| const sizes = keys.reduce( | |
| /** | |
| * @param {Record<string, StorageSize>} sizes | |
| * @param {string} key | |
| * @returns {Record<string, StorageSize>} | |
| */ | |
| (sizes, key) => { | |
| const val = GM_getValue(key); | |
| const json = JSON.stringify(val); | |
| const blob = new Blob([ json ], { type: 'text/plain' }); | |
| const size = blob.size; | |
| const MaxSize = CONST.Internal.Doctor.MaximumStorageSize; | |
| sizes[key] = { | |
| size, | |
| oversize: size > MaxSize, | |
| ratio: MaxSize > 0 ? size / MaxSize : Infinity, | |
| }; | |
| return sizes; | |
| }, {} | |
| ); | |
| return { | |
| result: sizes, | |
| pass: debugging.script_debug || Object.values(sizes).every(size => !size.oversize), | |
| }; | |
| } | |
| } | |
| }; | |
| const results = runTests(tests); | |
| const all_passed = Object.values(results).every(r => r.pass); | |
| debugging.script_debug && logger.log('Debug', 'doctor test results:', results); | |
| all_passed ? | |
| logger.log('Info', 'doctor: all tests passed') : | |
| logger.error('Error', 'doctor: test(s) failed'); | |
| /** | |
| * 执行测试并给出测试结果 | |
| * @template {Record<string, Test>} T | |
| * @param {T} tests | |
| * @returns {Record<keyof T, TestResult>} | |
| */ | |
| function runTests(tests) { | |
| return Object.entries(tests).reduce((result, [key, test]) => Object.assign(result, { [key]: test.func() }), {}); | |
| } | |
| /** | |
| * 深度检查两个对象的结构类型是否一致 | |
| * @param {object} obj1 第一个对象 | |
| * @param {object} obj2 第二个对象 | |
| * @param {string} path 当前检查的路径(内部使用) | |
| * @returns {{ same: boolean, [diff]: any }} same表示结构类型是否完全一致,diff是一个仅人类可读的说明性字段,表示在检查哪里时发现了不一致,如果same为true则diff字段无效 | |
| */ | |
| function isSameStructure(obj1, obj2, path = 'root') { | |
| // 处理基本类型情况 - 只检查类型,不检查值 | |
| if (typeof obj1 !== typeof obj2) { | |
| return { | |
| same: false, | |
| diff: { | |
| path: path, | |
| reason: '类型不一致', | |
| details: { | |
| obj1Type: typeof obj1, | |
| obj2Type: typeof obj2, | |
| obj1ValueSample: obj1, | |
| obj2ValueSample: obj2 | |
| } | |
| }, | |
| }; | |
| } | |
| // 处理非对象类型(包括 null)- 使用typeof确保类型相同 | |
| if ((typeof obj1 !== 'object' && typeof obj2 !== 'object') || obj1 === null || obj2 === null) { | |
| // 对于基本类型,只要typeof结果相同就认为结构一致,不检查具体值 | |
| const type1 = typeof obj1; | |
| const type2 = typeof obj2; | |
| const sameType = type1 === type2; | |
| return { | |
| same: sameType, | |
| diff: sameType ? undefined : { | |
| path: path, | |
| reason: '基本类型不一致', | |
| details: { | |
| obj1Type: type1, | |
| obj2Type: type2, | |
| obj1Value: obj1, | |
| obj2Value: obj2 | |
| } | |
| } | |
| }; | |
| } | |
| // 处理数组 | |
| if (Array.isArray(obj1) || Array.isArray(obj2)) { | |
| if (!Array.isArray(obj1) || !Array.isArray(obj2)) { | |
| return { | |
| same: false, | |
| diff: { | |
| path: path, | |
| reason: '类型不一致(一个是数组,一个不是)', | |
| details: { | |
| obj1: { type: Array.isArray(obj1) ? 'array' : typeof obj1, isArray: Array.isArray(obj1) }, | |
| obj2: { type: Array.isArray(obj2) ? 'array' : typeof obj2, isArray: Array.isArray(obj2) } | |
| } | |
| }, | |
| }; | |
| } | |
| if (obj1.length !== obj2.length) { | |
| return { | |
| same: false, | |
| diff: { | |
| path: path, | |
| reason: '数组长度不一致', | |
| details: { | |
| obj1Length: obj1.length, | |
| obj2Length: obj2.length | |
| } | |
| } | |
| }; | |
| } | |
| // 检查数组元素类型 | |
| for (let i = 0; i < obj1.length; i++) { | |
| const sub_result = isSameStructure(obj1[i], obj2[i], `${path}[${i}]`); | |
| if (!sub_result.same) { | |
| return sub_result; | |
| } | |
| } | |
| return { same: true }; | |
| } | |
| // 获取两个对象的所有属性名 | |
| const keys1 = Object.keys(obj1); | |
| const keys2 = Object.keys(obj2); | |
| // 检查属性数量是否相同 | |
| if (keys1.length !== keys2.length) { | |
| const missingInObj2 = keys1.filter(key => !keys2.includes(key)); | |
| const missingInObj1 = keys2.filter(key => !keys1.includes(key)); | |
| return { | |
| same: false, | |
| diff: { | |
| path: path, | |
| reason: '对象属性数量不同', | |
| details: { | |
| obj1KeysCount: keys1.length, | |
| obj2KeysCount: keys2.length, | |
| missingInObj2: missingInObj2.length > 0 ? missingInObj2 : '无', | |
| missingInObj1: missingInObj1.length > 0 ? missingInObj1 : '无', | |
| commonKeysCount: keys1.filter(key => keys2.includes(key)).length | |
| } | |
| }, | |
| }; | |
| } | |
| // 检查所有属性名是否相同且类型一致 | |
| for (const key of keys1) { | |
| if (!keys2.includes(key)) { | |
| return { | |
| same: false, | |
| diff: { | |
| path: path, | |
| reason: '属性缺失', | |
| details: { | |
| missingKey: key, | |
| missingIn: 'obj2', | |
| obj1ValueType: typeof obj1[key] | |
| } | |
| } | |
| }; | |
| } | |
| const sub_result = isSameStructure(obj1[key], obj2[key], `${path}.${key}`); | |
| if (!sub_result.same) { | |
| return sub_result; | |
| } | |
| } | |
| return { same: true }; | |
| } | |
| } | |
| }, | |
| dependencies: { | |
| desc: 'load dependencies like vue into the page', | |
| detectDom: ['head', 'body'], | |
| async func() { | |
| const StandbySuffix = '-bak'; | |
| const deps = [{ | |
| name: 'vue-js', | |
| type: 'script', | |
| }, { | |
| name: 'quasar-icon', | |
| type: 'style' | |
| }, { | |
| name: 'quasar-css', | |
| type: 'style' | |
| }, { | |
| name: 'quasar-js', | |
| type: 'script' | |
| }]; | |
| await Promise.all(deps.map(dep => { | |
| return new Promise((resolve, reject) => { | |
| const resource_text = GM_getResourceText(dep.name) || GM_getResourceText(dep.name + StandbySuffix); | |
| switch (dep.type) { | |
| case 'script': { | |
| // Once load, dispatch load event on messager | |
| const evt_name = `load:${dep.name};${Date.now()}`; | |
| const rand = Math.random().toString(); | |
| const messager = new EventTarget(); | |
| const load_code = [ | |
| '\n;', | |
| `window[${escJsStr(rand)}].dispatchEvent(new Event(${escJsStr(evt_name)}));`, | |
| `delete window[${escJsStr(rand)}];\n` | |
| ].join('\n'); | |
| unsafeWindow[rand] = messager; | |
| $AEL(messager, evt_name, resolve); | |
| GM_addElement(document.head, 'script', { | |
| textContent: `/* ${dep.name} */\n` + resource_text + load_code, | |
| }); | |
| break; | |
| } | |
| case 'style': { | |
| GM_addElement(document.head, 'style', { | |
| textContent: `/* ${dep.name} */\n` + resource_text, | |
| }); | |
| resolve(); | |
| break; | |
| } | |
| } | |
| }); | |
| })); | |
| // 创建一个Vue app并调用Quasar以进行初始化,以使用Quasar插件(Quasar.Dialog, Quasar.Loading等等) | |
| const app = Vue.createApp({}); | |
| app.use(Quasar); | |
| // configurations | |
| Quasar.setCssVar('primary', '#6f9ff1'); | |
| //Quasar.setCssVar('secondary', '#12b5a5'); | |
| Quasar.setCssVar('negative', '#e63c4f'); | |
| require('darkmode', true).then( | |
| /** @param {darkmode} darkmode */ | |
| darkmode => setTimeout(() => Quasar.Dark.set(darkmode.actual_enabled)) | |
| ); | |
| addStyle(` | |
| /* 自动对应深色和浅色模式的背景颜色和文字颜色 */ | |
| .body--light .text-lightdark { | |
| color: black; | |
| } | |
| .body--light .bg-lightdark { | |
| background: #fff; | |
| } | |
| .body--dark .text-lightdark { | |
| color: #fff; | |
| } | |
| .body--dark .bg-lightdark { | |
| background: var(--q-dark); | |
| } | |
| .body--light .bg-active { | |
| background: #EDEDED; | |
| } | |
| .body--dark .bg-active { | |
| background: #2A2A2A; | |
| } | |
| `); | |
| addStyle(` | |
| .tippy-box[data-theme~='lightdark'] { | |
| background-color: #fff; | |
| color: #000; | |
| border: 1px solid var(--q-primary); | |
| } | |
| .tippy-arrow { | |
| color: var(--q-primary); | |
| } | |
| .plus-darkmode .tippy-box[data-theme~='lightdark'] { | |
| background-color: #333; | |
| color: #fff; | |
| } | |
| `); | |
| addStyle(` | |
| :root { | |
| --p-primary: #0d548b; | |
| } | |
| `); | |
| Quasar.Notify.registerType('info', { | |
| color: 'lightdark', | |
| textColor: 'lightdark', | |
| icon: 'info', | |
| iconColor: 'primary', | |
| position: 'top-right', | |
| badgeColor: 'primary', | |
| badgeTextColor: 'lightdark', | |
| }); | |
| Quasar.Notify.registerType('success', { | |
| color: 'lightdark', | |
| textColor: 'lightdark', | |
| icon: 'done', | |
| iconColor: 'primary', | |
| position: 'top-right', | |
| badgeColor: 'primary', | |
| badgeTextColor: 'lightdark', | |
| }); | |
| Quasar.Notify.registerType('warning', { | |
| color: 'lightdark', | |
| textColor: 'warning', | |
| icon: 'info', | |
| iconColor: 'warning', | |
| position: 'top-right', | |
| badgeColor: 'warning', | |
| badgeTextColor: 'lightdark', | |
| }); | |
| Quasar.Notify.registerType('error', { | |
| color: 'lightdark', | |
| textColor: 'negative', | |
| icon: 'close', | |
| iconColor: 'negative', | |
| position: 'top-right', | |
| badgeColor: 'negative', | |
| badgeTextColor: 'lightdark', | |
| }); | |
| Quasar.LoadingBar.setDefaults({ | |
| hijackFilter(url) { | |
| return false; | |
| } | |
| }); | |
| // some fixes | |
| addStyle(` | |
| *:where([class*="q-"], [class*="q-"]:not(body) *) { | |
| font-family: Roboto,-apple-system,Helvetica Neue,Helvetica,Arial,sans-serif; | |
| } | |
| *:not([class*="q-"], [class*="q-"]:not(body) *) { | |
| box-sizing: content-box; | |
| } | |
| *:where([class*="q-"]:not(body), [class*="q-"]:not(body) *), :after, :before { | |
| box-sizing: border-box; | |
| } | |
| p:where(:not([class*="q-"])) { | |
| margin: unset; | |
| } | |
| [class*="q-"]:not(body) .block:not(.plus-preserve-border) { | |
| border: none; | |
| } | |
| [class*="q-"]:not(body) .block { | |
| margin-bottom: 0; | |
| } | |
| `); | |
| const loadStyle = () => addStyle(` | |
| body { | |
| ${ | |
| $('link[href="/configs/article/page.css"]') ? | |
| 'font-family: 宋体,新细明体,Verdana,Arial,sans-serif;' : | |
| 'font: 12px/120% 宋体,Verdana,Arial,sans-serif;' | |
| } | |
| line-height: unset; | |
| } | |
| `); | |
| document.readyState === 'loading' ? $AEL(document, 'DOMContentLoaded', e => loadStyle()) : loadStyle(); | |
| } | |
| }, | |
| api: { | |
| dependencies: ['utils', 'debugging'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.api.func>>} api */ | |
| func() { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {debugging} */ | |
| const debugging = require('debugging'); | |
| /** | |
| * 根据API返回的数字代码获取错误信息 | |
| * @param {number} errcode | |
| */ | |
| function getErrorInfo(errcode) { | |
| return ({ | |
| 0: '请求发生错误', | |
| 1: '成功(登陆、添加、删除、发帖)', | |
| 2: '用户名错误', | |
| 3: '密码错误', | |
| 4: '请先登录', | |
| 5: '已经在书架', | |
| 6: '书架已满', | |
| 7: '小说不在书架', | |
| 8: '回复帖子主题不存在', | |
| 9: '签到失败', | |
| 10: '推荐失败', | |
| 11: '帖子发送失败', | |
| 22: 'refer page 0' | |
| }) [errcode] ?? `未知错误 ${errcode}`; | |
| } | |
| /** | |
| * encode request data param for wenku8 api | |
| * @param {string} str | |
| * @returns {string} | |
| */ | |
| function encode(str) { | |
| return '&appver=1.13&request=' + btoa(str) + '&timetoken=' + (new Date().getTime()); | |
| } | |
| /** | |
| * @param {Object} detail | |
| * @param {string} detail.url | |
| * @returns {Promise<string>} | |
| */ | |
| async function _request({ url }) { | |
| const { promise, resolve, reject } = Promise.withResolvers(); | |
| GM_xmlhttpRequest({ | |
| method: 'POST', | |
| url: 'http://app.wenku8.com/android.php', | |
| headers: { | |
| 'Content-Type': 'application/x-www-form-urlencoded', | |
| 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 7.1.2; unknown Build/NZH54D)' | |
| }, | |
| data: encode(url), | |
| onload(response) { | |
| if (response.status !== 200) { | |
| const err = new Error('Network error while fetching api'); | |
| debugging.saveError({ | |
| type: 'api', | |
| error: err, | |
| info: { url } | |
| }); | |
| reject(response); | |
| } | |
| resolve(response.responseText); | |
| }, | |
| onerror(err) { | |
| reject(err); | |
| } | |
| }); | |
| return promise; | |
| } | |
| const request = utils.toQueued(_request, { | |
| max: 5, | |
| sleep: 0, | |
| queue_id: 'api_request' | |
| }); | |
| /** | |
| * 请求api并将返回字符串解析为XML文档 | |
| * 如果返回字符串无法解析为XML文档,则返回原始字符串 | |
| * @param {Parameters<typeof request>} args | |
| * @returns {Promise<ReturnType<typeof parseXML> | string>} | |
| */ | |
| async function requestXML(...args) { | |
| const xml_source = await request(...args); | |
| try { | |
| return parseXML(xml_source); | |
| } catch (err) { | |
| return xml_source; | |
| } | |
| } | |
| /** | |
| * 将传入的字符串按照XML解析为XMLDocument,如果格式错误不能解析则显式报错 | |
| * @param {string} xml_source | |
| * @returns {XMLDocument} | |
| */ | |
| function parseXML(xml_source) { | |
| const parser = new DOMParser(); | |
| const xml = parser.parseFromString(xml_source, 'text/xml'); | |
| Assert(!xml.querySelector('parsererror'), 'parse error', Error); | |
| return xml; | |
| } | |
| /** | |
| * 获取书籍简要信息 | |
| * @param {Object} detail | |
| * @param {number | string} detail.aid - 文库书籍ID | |
| * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} | |
| * @returns {Promise<XMLDocument>} | |
| */ | |
| async function getNovelShortInfo({ aid, lang }) { | |
| return requestXML({ | |
| url: `action=book&do=info&aid=${aid}&t=${lang}` | |
| }); | |
| } | |
| /** | |
| * 获取书籍信息(升级版) | |
| * 实测也就多了个tags数据 | |
| * @param {Object} detail | |
| * @param {number | string} detail.aid - 文库书籍ID | |
| * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} | |
| * @returns {Promise<XMLDocument>} | |
| */ | |
| async function getNovelInfo({ aid, lang }) { | |
| return requestXML({ | |
| url: `action=book&do=bookinfo&aid=${aid}&t=${lang}` | |
| }); | |
| } | |
| /** | |
| * 获取书籍完整元信息 | |
| * @param {Object} detail | |
| * @param {number | string} detail.aid - 文库书籍ID | |
| * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} | |
| * @returns {Promise<XMLDocument>} | |
| */ | |
| async function getNovelFullMeta({ aid, lang }) { | |
| return requestXML({ | |
| url: `action=book&do=meta&aid=${aid}&t=${lang}` | |
| }); | |
| } | |
| /** | |
| * 获取书籍完整简介 | |
| * @param {Object} detail | |
| * @param {number | string} detail.aid - 文库书籍ID | |
| * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} | |
| * @returns {Promise<string>} | |
| */ | |
| async function getNovelFullIntro({ aid, lang }) { | |
| return request({ | |
| url: `action=book&do=intro&aid=${aid}&t=${lang}` | |
| }); | |
| } | |
| /** | |
| * 获取书籍封面图片 | |
| * @param {Object} detail | |
| * @param {number | string} detail.aid - 文库书籍ID | |
| * @returns {Promise<string>} | |
| */ | |
| async function getNovelCover({ aid }) { | |
| return request({ | |
| url: `action=book&do=cover&aid=${aid}` | |
| }); | |
| } | |
| /** | |
| * 获取书籍目录 | |
| * @param {Object} detail | |
| * @param {number | string} detail.aid - 文库书籍ID | |
| * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} | |
| * @returns {Promise<XMLDocument>} | |
| */ | |
| async function getNovelIndex({ aid, lang }) { | |
| return requestXML({ | |
| url: `action=book&do=list&aid=${aid}&t=${lang}` | |
| }); | |
| } | |
| /** | |
| * 获取某一章节内容 | |
| * @param {Object} detail | |
| * @param {number | string} detail.aid - 文库书籍ID | |
| * @param {number | string} detail.cid - 文库章节ID | |
| * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} | |
| * @returns {Promise<string>} | |
| */ | |
| async function getNovelContent({ aid, cid, lang }) { | |
| return request({ | |
| url: `action=book&do=text&aid=${aid}&cid=${cid}&t=${lang}` | |
| }); | |
| } | |
| /** | |
| * 获取用户信息 | |
| * @returns {Promise<XMLDocument>} | |
| */ | |
| async function getUserInfo() { | |
| return requestXML({ | |
| url: 'action=userinfo' | |
| }); | |
| } | |
| /** | |
| * 获取某一书评内容 | |
| * @param {Object} detail | |
| * @param {number | string} detail.rid - 书评ID | |
| * @param {number | string} detail.page - 书评页码 | |
| * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} | |
| * @returns {Promise<XMLDocument>} | |
| */ | |
| async function getReviewContent({ rid, page, lang }) { | |
| return requestXML({ | |
| url: `action=review&do=show&rid=${rid}&page=${page}&t=${lang}` | |
| }); | |
| } | |
| /** | |
| * 根据书名搜索书籍 | |
| * @param {Object} detail | |
| * @param {string} detail.search_key - 搜索内容 | |
| * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} | |
| * @returns {Promise<XMLDocument>} | |
| */ | |
| async function searchNovelByNovelName({ search_key, lang }) { | |
| return requestXML({ | |
| url: `action=search&searchtype=articlename&searchkey=${ encodeURIComponent(search_key) }&t=${ lang }`, | |
| }); | |
| } | |
| /** | |
| * 根据书名搜索书籍 | |
| * @param {Object} detail | |
| * @param {string} detail.search_key - 搜索内容 | |
| * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} | |
| * @returns {Promise<XMLDocument>} | |
| */ | |
| async function searchNovelByAuthorName({ search_key, lang }) { | |
| return requestXML({ | |
| url: `action=search&searchtype=author&searchkey=${ encodeURIComponent(search_key) }&t=${ lang }`, | |
| }); | |
| } | |
| /** | |
| * 用户登录,可选通过用户名或邮箱登录 | |
| * 也许需要注意:纯http请求+明文密码或许是安全性的地狱 | |
| * @param {string} username - username or email | |
| * @param {string} password | |
| * @param {boolean} [useEmail=false] | |
| */ | |
| async function login(username, password, useEmail = false) { | |
| return request({ | |
| url: `action=${useEmail ? 'loginemail' : 'login'}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}` | |
| }); | |
| } | |
| /** | |
| * 退出登录 | |
| */ | |
| async function logout() { | |
| return request({ | |
| url: 'action=logout' | |
| }); | |
| } | |
| return { | |
| getErrorInfo, encode, request, requestXML, | |
| getNovelShortInfo, getNovelInfo, getNovelFullMeta, getNovelFullIntro, getNovelCover, getNovelIndex, getNovelContent, searchNovelByNovelName, searchNovelByAuthorName, | |
| getUserInfo, login, logout, | |
| getReviewContent, | |
| }; | |
| } | |
| }, | |
| sidepanel: { | |
| desc: '工具栏按钮', | |
| dependencies: ['dependencies', 'debugging', 'utils'], | |
| detectDom: 'body', | |
| /** @typedef {Awaited<ReturnType<typeof functions.sidepanel.func>>} sidepanel */ | |
| func() { | |
| /** @type {debugging} */ | |
| const debugging = require('debugging'); | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| let instance; | |
| /** | |
| * @callback ButtonCallback | |
| * @param {PointerEvent} e | |
| */ | |
| /** | |
| * 按钮类型,不同类型按钮会通过不同外观给予用户不同视觉提示 | |
| * @typedef {'universal' | 'functional'} ButtonType | |
| */ | |
| /** | |
| * 按钮数据 | |
| * @typedef {Object} Button | |
| * @property {string} id - 按钮id,需全局唯一 | |
| * @property {string} label | |
| * @property {string} icon | |
| * @property {boolean} loading - 按钮是否置于"加载中"状态 | |
| * @property {ButtonType} [type='functional'] | |
| * @property {ButtonCallback} callback - 按钮点击回调,带点击事件 | |
| * @property {number} index - button的位置,按钮排序顺序:上 <== -1 -2 -3 ... 3 2 1 <== 下 | |
| */ | |
| const container = $CrE('div'); | |
| container.innerHTML = ` | |
| <div class="plus-sidepanel q-mt-md"> | |
| <q-fab | |
| square | |
| external-label | |
| label="${ CONST.Text.SidePanel.PanelShowHide }" | |
| label-position="left" | |
| vertical-actions-align="center" | |
| color="primary" | |
| icon="keyboard_arrow_up" | |
| direction="up" | |
| padding="0.75em" | |
| label-style="font-size: 1em; line-height: 1.715em;" | |
| v-model="expanded" | |
| > | |
| <q-fab-action v-for="button of buttons" | |
| external-label | |
| square padding="0.75em" | |
| :color="ButtonColors[button.type]" | |
| label-position="left" | |
| @click="onClick.call(this, $event, button.callback)" | |
| :icon="button.icon" | |
| :label="button.label" | |
| :loading="!!button.loading" | |
| label-style="font-size: 1em; line-height: 1.715em;" | |
| ></q-fab-action> | |
| </q-fab> | |
| </div> | |
| `; | |
| document.body.append(container); | |
| addStyle(` | |
| .plus-sidepanel { | |
| position: fixed; | |
| right: 2em; | |
| bottom: 2em; | |
| } | |
| `); | |
| const app = Vue.createApp({ | |
| data() { | |
| return { | |
| /** @type {Button[]} */ | |
| buttons: [], | |
| expanded: false, | |
| }; | |
| }, | |
| computed: { | |
| ButtonColors() { | |
| return { | |
| 'universal': 'primary', | |
| 'functional': 'secondary', | |
| }; | |
| } | |
| }, | |
| methods: { | |
| /** | |
| * 按钮被点击: | |
| * 1. 阻止侧边栏自动折叠 | |
| * 2. 带错误处理地执行按钮回调 | |
| * @param {PointerEvent} e | |
| * @param {ButtonCallback} callback | |
| */ | |
| onClick(e, callback) { | |
| this.expanded = true; | |
| debugging.callWithErrorHandling(callback, this, [e]); | |
| }, | |
| }, | |
| mounted() { | |
| // Vue作用域外使用instance引用this | |
| // 本作用域依然属于Vue作用域内,按照原则使用that | |
| const that = instance = this; | |
| // 点击侧边栏以外的文档任意位置,隐藏侧边栏 | |
| $AEL(document, 'click', e => { | |
| if (!container.contains(e.target)) { | |
| that.expanded = false; | |
| } | |
| }); | |
| } | |
| }); | |
| app.use(Quasar); | |
| app.mount(container); | |
| /** | |
| * 注册一个新按钮到侧边栏 | |
| * 每次有新按钮注册或已有按钮移除都会重新排序所有按钮,保证顺序符合index升序 | |
| * @param {Button} button | |
| */ | |
| function registerButton(button) { | |
| // 检查id是否全局唯一 | |
| Assert( | |
| !hasButton(button.id), | |
| `duplicate button id ${escJsStr(button.id)}` | |
| ); | |
| // 先克隆button对象,防止后续外部代码修改产生影响 | |
| button = Object.assign({}, button); | |
| // 补充可选属性默认值 | |
| !button.type && (button.type = 'functional'); | |
| // 添加到UI中 | |
| instance.buttons.push(button); | |
| // 重新排序 | |
| instance.buttons.sort((btn1, btn2) => { | |
| // 上 <== -1 -2 -3 ... 3 2 1 <== 下 | |
| const [i1, i2] = [btn1.index, btn2.index]; | |
| if (i1 * i2 > 0) { | |
| // [1, 2, 3, ...] | [..., -3, -2, -1] | |
| return btn1.index - btn2.index; | |
| } else { | |
| // positive, negative | |
| return i1 < 0 ? 1 : -1; | |
| } | |
| }); | |
| } | |
| /** | |
| * 从侧边栏移除一个按钮 | |
| * @param {string} id - 按钮id | |
| * @returns {Button} 被移除的按钮 | |
| */ | |
| function removeButton(id) { | |
| // 检查按钮是否存在 | |
| Assert( | |
| hasButton(id), | |
| `No button found with id ${escJsStr(id)}` | |
| ); | |
| // 移除按钮 | |
| const index = instance.buttons.findIndex(btn => btn.id === id); | |
| return index >= 0 ? instance.buttons.splice(index, 1) : null; | |
| } | |
| /** | |
| * 更新已注册按钮的属性 | |
| * @param {string} id - 按钮id | |
| * @param {Partial<Button>} props - 需要修改的按钮属性-值 | |
| */ | |
| function updateButton(id, props) { | |
| // 检查按钮是否存在 | |
| Assert( | |
| hasButton(id), | |
| `No button found with id ${escJsStr(id)}` | |
| ); | |
| // 更新按钮 | |
| const button = instance.buttons.find(btn => btn.id === id); | |
| Object.assign(button, props); | |
| } | |
| /** | |
| * 检查指定id对应的按钮是否存在 | |
| * @param {string} id | |
| * @returns | |
| */ | |
| function hasButton(id) { | |
| return instance.buttons.some(btn => btn.id === id); | |
| } | |
| // 注册一些通用按钮 | |
| registerButton({ | |
| id: 'JumpToTop', | |
| label: CONST.Text.SidePanel.GotoTop, | |
| icon: 'keyboard_arrow_up', | |
| type: 'universal', | |
| index: -1, | |
| callback() { | |
| const elms = [document.body.parentElement, $('#content'), $('#contentmain')]; | |
| for (const elm of elms) { | |
| elm && elm.scrollTo && elm.scrollTo({ | |
| left: elm.scrollLeft, | |
| top: 0, | |
| behavior: 'smooth' | |
| }); | |
| } | |
| } | |
| }); | |
| registerButton({ | |
| id: 'JumpToBottom', | |
| label: CONST.Text.SidePanel.GotoBottom, | |
| icon: 'keyboard_arrow_down', | |
| type: 'universal', | |
| index: -2, | |
| callback() { | |
| const elms = [document.body.parentElement, $('#content'), $('#contentmain')]; | |
| for (const elm of elms) { | |
| elm && elm.scrollTo && elm.scrollTo({ | |
| left: elm.scrollLeft, | |
| top: elm.scrollHeight, | |
| behavior: 'smooth' | |
| }); | |
| } | |
| } | |
| }); | |
| registerButton({ | |
| id: 'RefreshPage', | |
| label: CONST.Text.SidePanel.Refresh, | |
| icon: 'refresh', | |
| type: 'universal', | |
| index: -3, | |
| callback() { | |
| const location = utils.window.top.location; | |
| if (location.href.includes('#')) { | |
| const url = new URL(location.href); | |
| url.searchParams.set('_t', Date.now().toString()); | |
| location.replace(url); | |
| } else { | |
| location.replace(location.href); | |
| } | |
| } | |
| }); | |
| return { | |
| /** | |
| * Read-only button instances | |
| * @type {Button[]} | |
| */ | |
| get buttons() { return Vue.toRaw(instance.buttons).map(btn => Object.assign({}, btn)) }, | |
| registerButton, removeButton, updateButton, hasButton, | |
| }; | |
| } | |
| }, | |
| history: { | |
| desc: '封装、管理浏览历史对象', | |
| /** @typedef {Awaited<ReturnType<typeof functions.history.func>>} history */ | |
| func() { | |
| let cur_url = location.href; | |
| /** | |
| * 基本等效于{@link History.prototype.pushState} | |
| * @param {Object} state - 任意可序列化的对象 | |
| * @param {string} unused - 由于历史原因存在此参数,传递一个空字符串是安全的 | |
| * @param {string} [url] - 地址,可以是绝对地址或是相对地址;绝对地址需要和当前网页同源,相对地址将会基于当前页面地址解析;若没有指定,则为当前文档的地址不变 | |
| */ | |
| function pushState(state, unused, url) { | |
| url = url ?? location.href; | |
| history.pushState(state, unused, url); | |
| cur_url = new URL(url, location.href).href; | |
| } | |
| /** | |
| * 基本等效于{@link History.prototype.replaceState} | |
| * @param {Object} state - 任意可序列化的对象 | |
| * @param {string} unused - 由于历史原因存在此参数,传递一个空字符串是安全的 | |
| * @param {string} [url] - 地址,可以是绝对地址或是相对地址;绝对地址需要和当前网页同源,相对地址将会基于当前页面地址解析;若没有指定,则为当前文档的地址不变 | |
| */ | |
| function replaceState(state, unused, url) { | |
| url = url ?? location.href; | |
| history.replaceState(state, unused, url); | |
| cur_url = new URL(url, location.href).href; | |
| } | |
| /** | |
| * 监听历史记录回退事件 | |
| * @param {(evt: PopStateEvent & { old_url: string, new_url: string }) => any} callback | |
| */ | |
| function onPopstate(callback) { | |
| $AEL(window, 'popstate', | |
| /** @param {PopStateEvent} e */ | |
| e => { | |
| const evt = new Proxy(e, { | |
| get(target, p, receiver) { | |
| return { | |
| // 额外提供的值 | |
| old_url: cur_url, | |
| new_url: location.href, | |
| // 由于套了层proxy,直接调用会出错,在这里手动绑定一下this | |
| preventDefault: target.preventDefault.bind(target), | |
| stopPropagation: target.stopPropagation.bind(target), | |
| stopImmediatePropagation: target.stopImmediatePropagation.bind(target), | |
| } [p] ?? target[p]; | |
| }, | |
| }); | |
| callback(evt); | |
| cur_url = location.href; | |
| return evt; | |
| } | |
| ); | |
| } | |
| return { pushState, replaceState, onPopstate }; | |
| } | |
| }, | |
| component: { | |
| desc: '自行实现的Vue组件,以及一些快捷UI', | |
| dependencies: ['dependencies'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.component.func>>} component */ | |
| func() { | |
| const components = { | |
| // 本地图片选择器 | |
| 'p-image-select': { | |
| data: { | |
| name: 'PImageSelect', | |
| props: ['modelValue'], | |
| emits: ['update:modelValue'], | |
| template: ` | |
| <q-img v-if="file" | |
| :src="img_src" | |
| style="width: 10em;" | |
| ></q-img> | |
| <q-btn v-show="!file" | |
| label="${ CONST.Text.Component.SelectImage }" | |
| @click="selectImage" | |
| flat | |
| ></q-btn> | |
| `, | |
| computed: { | |
| // v-model | |
| file: { | |
| get() { | |
| return this.modelValue; | |
| }, | |
| set(file) { | |
| this.$emit('update:modelValue', file); | |
| } | |
| }, | |
| // 图片src | |
| img_src(_, old_src) { | |
| old_src && URL.revokeObjectURL(old_src); | |
| return URL.createObjectURL(this.modelValue); | |
| }, | |
| }, | |
| methods: { | |
| /** | |
| * 用户选择图片 | |
| */ | |
| selectImage() { | |
| const that = this; | |
| $$CrE({ | |
| tagName: 'input', | |
| props: { | |
| type: 'file', | |
| }, | |
| listeners: [['change', async e => { | |
| /** @type {HTMLInputElement} */ | |
| const input = e.target; | |
| const file = input.files[0]; | |
| that.file = file; | |
| }]] | |
| }).click(); | |
| }, | |
| } | |
| }, | |
| }, | |
| // 颜色选择器 | |
| 'p-color': { | |
| data: { | |
| name: 'PColor', | |
| props: ['modelValue'], | |
| emits: ['update:modelValue'], | |
| data() { | |
| return { | |
| picker_visible: false, | |
| } | |
| }, | |
| template: ` | |
| <q-input | |
| v-model="color" | |
| :rules="['anyColor']" | |
| @focus="() => picker_visible = true" | |
| > | |
| <template v-slot:prepend> | |
| <div | |
| :style="{ width: '1em', height: '1em', background: color, borderRadius: '30%', cursor: 'pointer' }" | |
| @click="() => picker_visible = true" | |
| ></div> | |
| </template> | |
| <template v-slot:append> | |
| <q-icon name="colorize" class="cursor-pointer"> | |
| <q-popup-proxy cover transition-show="scale" transition-hide="scale" v-model="picker_visible"> | |
| <q-color v-model="color" default-view="palette"></q-color> | |
| </q-popup-proxy> | |
| </q-icon> | |
| </template> | |
| </q-input> | |
| `, | |
| computed: { | |
| color: { | |
| get() { | |
| return this.modelValue; | |
| }, | |
| set(color) { | |
| this.$emit('update:modelValue', color); | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| // 列表单选类型 | |
| 'p-choose': { | |
| data: { | |
| name: 'PChoose', | |
| props: ['modelValue', 'options'], | |
| emits: ['update:modelValue'], | |
| template: ` | |
| <q-btn :label="brief" icon-right="keyboard_arrow_down" flat> | |
| <q-popup-proxy> | |
| <q-list> | |
| <q-item v-for="option of options" tag="label"> | |
| <q-radio | |
| v-model="value" | |
| :val="option.value" | |
| :label="option.label" | |
| ></q-radio> | |
| </q-item> | |
| </q-list> | |
| </q-popup-proxy> | |
| </q-btn> | |
| `, | |
| computed: { | |
| value: { | |
| get() { | |
| return this.modelValue; | |
| }, | |
| set(val) { | |
| this.$emit('update:modelValue', val); | |
| }, | |
| }, | |
| brief() { | |
| return this.options.find(o => o.value === this.value)?.brief ?? Component.PleaseChoose; | |
| } | |
| }, | |
| }, | |
| }, | |
| // 浮点数类型 | |
| 'p-number': { | |
| data: { | |
| name: 'PNumber', | |
| props: ['modelValue'], | |
| emits: ['update:modelValue'], | |
| template: ` | |
| <q-input | |
| v-model="number" | |
| :rules="[val => /^-?\\d+(\\.\\d+)?$/.test(val) || ${ escJsStr(CONST.Text.Component.InputMustBeFloat, "'") }]" | |
| ></q-input> | |
| `, | |
| computed: { | |
| number: { | |
| get() { | |
| return this.modelValue.toString(); | |
| }, | |
| set(number) { | |
| number = parseFloat(number); | |
| isNaN(number) || this.$emit('update:modelValue', number); | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| // 可添加项目的下拉选择框组件 | |
| 'p-addable-select': { | |
| data: { | |
| name: 'PAddableSelect', | |
| props: ['modelValue', 'options', 'option-handler'], | |
| emits: ['update:modelValue', 'update:options'], | |
| template: ` | |
| <q-select | |
| :options="display_options" | |
| v-model="value" | |
| use-input | |
| input-debounce="0" | |
| @filter="filterFn" | |
| @new-value="createValue" | |
| emit-value | |
| map-options | |
| > | |
| <template v-slot:option="{ itemProps, opt, index }"> | |
| <q-item v-bind="itemProps"> | |
| <q-item-section> | |
| <q-item-label v-html="opt.label"></q-item-label> | |
| </q-item-section> | |
| <q-item-section side> | |
| <q-btn flat dense icon="close" @click="e => removeValue(e, index)" square></q-btn> | |
| </q-item-section> | |
| </q-item> | |
| </template> | |
| </q-select> | |
| `, | |
| data() { | |
| return { | |
| display_options: [...this.options], | |
| }; | |
| }, | |
| methods: { | |
| createValue(val, done) { | |
| if (val.length > 0) { | |
| if (this.options.every(opt => this.getOptionValue(opt) !== this.getOptionValue(val))) { | |
| typeof this.optionHandler === 'function' && (val = this.optionHandler(val)); | |
| this.options.push(val); | |
| } | |
| done(val, 'add-unique'); | |
| } | |
| }, | |
| removeValue(e, index) { | |
| // 停止事件冒泡,阻止quasar试图切换到这个将要移除的选项 | |
| e.stopPropagation(); | |
| // 从完整选项列表删除选项 | |
| const dropped_opt = this.options.splice(index, 1)[0]; | |
| // 从显示的选项列表删除选项 | |
| [...this.display_options].forEach(opt => { | |
| if (utils.deepEqual(opt, dropped_opt)) { | |
| const i = this.display_options.indexOf(opt); | |
| this.display_options.splice(i, 1); | |
| } | |
| }); | |
| // 如果当前选中的是被移除的选项,就改为选中选项列表第一项 | |
| if (this.value === this.getOptionValue(dropped_opt)) { | |
| // 当显示的选项列表不为空时,优先从显示的选项列表中取;否则从全部选项列表中取 | |
| // 当均为空时,回退到空值 | |
| const option = this.display_options.length ? this.display_options[0] : this.options[0] ?? null; | |
| this.value = option !== null ? this.getOptionValue(option) : ''; | |
| } | |
| }, | |
| filterFn(val, update = null) { | |
| const that = this; | |
| update = typeof update === 'function' ? update : setTimeout; | |
| update(() => { | |
| if (val === '') { | |
| that.display_options = [...that.options]; | |
| } else { | |
| const needle = val.toLowerCase(); | |
| that.display_options = that.options.filter(v => v.value.toLowerCase().includes(needle)); | |
| } | |
| }); | |
| }, | |
| /** | |
| * 获取选项的值 | |
| * @param {string | { label: string, value: string }} opt | |
| * @returns | |
| */ | |
| getOptionValue(opt) { | |
| return typeof opt === 'string' ? opt : opt.value; | |
| } | |
| }, | |
| watch: { | |
| options: { | |
| handler(new_val, old_val) { | |
| this.$emit('update:options', new_val); | |
| //this.filterFn(this.value); | |
| }, | |
| deep: true, | |
| } | |
| }, | |
| computed: { | |
| value: { | |
| get() { | |
| return this.modelValue; | |
| }, | |
| set(val) { | |
| this.$emit('update:modelValue', val); | |
| } | |
| }, | |
| }, | |
| }, | |
| }, | |
| // 嵌套进度组件 | |
| /** | |
| * 该组件与ProgressManager紧密耦合,需传入标准化的ProgressManager实例参数 | |
| * 传入的ProgressManager的info属性对象必须具有以下属性: | |
| * - @property {string} label 该条目显示的主文字 | |
| * - @property {string} caption 该条目显示的副文字 | |
| * - @property {string} icon 该条目的图标 | |
| */ | |
| 'p-progress': { | |
| data: { | |
| name: 'PProgress', | |
| props: ['manager'], | |
| emits: [], | |
| template: ` | |
| <q-item class="column" style="user-select: none;"> | |
| <q-linear-progress | |
| :value="progress" | |
| :color="color" | |
| :indeterminate="indeterminate" | |
| ></q-linear-progress> | |
| <q-expansion-item | |
| v-if="sub_managers.length" | |
| expand-separator | |
| :icon="icon" | |
| :label="label" | |
| :caption="caption" | |
| > | |
| <p-progress | |
| v-for="sub_manager of sub_managers" | |
| :manager="sub_manager" | |
| ></p-progress> | |
| </q-expansion-item> | |
| <q-item v-else clickable> | |
| <q-item-section avatar> | |
| <q-icon :name="icon"></q-icon> | |
| </q-item-section> | |
| <q-item-section> | |
| <q-item-label>{{ label }}</q-item-label> | |
| <q-item-label caption>{{ caption }}</q-item-label> | |
| </q-item-section> | |
| </q-item> | |
| </q-item> | |
| `, | |
| data() { | |
| return { | |
| // 存储进度 | |
| finished: this.manager.finished, | |
| total: this.manager.steps, | |
| error: this.manager.error, | |
| // 存储info | |
| info: this.manager.info, | |
| // 存储子级进度管理器 | |
| sub_managers: this.manager.children, | |
| }; | |
| }, | |
| computed: { | |
| // 显示文字 | |
| icon() { return this.info?.icon ?? 'draft' }, | |
| label() { return this.info?.label ?? '' }, | |
| caption() { return this.info?.caption ?? '' }, | |
| // 进度数据 | |
| progress() { | |
| return this.total !== 0 ? this.finished / this.total : 0; | |
| }, | |
| color() { | |
| return ({ | |
| none: this.finished === this.total && this.total > 0 ? 'green' : 'blue', | |
| sub: this.finished === this.total ? 'orange' : 'blue', | |
| self: 'red' | |
| })[this.error]; | |
| }, | |
| indeterminate() { | |
| return this.total === 0 && this.error !== 'self'; | |
| }, | |
| }, | |
| watch: { | |
| manager: { | |
| // 当进度管理器更新时,同步更新实例存储的进度和子级进度管理器 | |
| handler(new_manager, old_manager) { | |
| const that = this; | |
| $AEL(new_manager, 'sub', e => { | |
| that.sub_managers = new_manager.children; | |
| }); | |
| $AEL(new_manager, 'progress', e => { | |
| that.finished = new_manager.finished; | |
| that.total = new_manager.steps; | |
| that.info = Object.assign({}, new_manager.info); | |
| }); | |
| $AEL(new_manager, 'error', e => { | |
| that.error = new_manager.error; | |
| }); | |
| $AEL(new_manager, 'reset', e => fullRefresh()); | |
| fullRefresh(); | |
| function fullRefresh() { | |
| that.sub_managers = new_manager.children; | |
| that.finished = new_manager.finished; | |
| that.total = new_manager.steps; | |
| that.error = new_manager.error; | |
| } | |
| }, | |
| immediate: true | |
| } | |
| } | |
| }, | |
| }, | |
| // 嵌套进度弹窗 | |
| // 封装了嵌套进度组件,使其可以通过dialog方法弹窗化展示 | |
| 'p-progress-dialog': { | |
| dependencies: 'p-progress', | |
| data: { | |
| name: 'PProgressDialog', | |
| props: ['modelValue'], | |
| emits: ['update:modelValue', 'submit', 'cancel'], | |
| template: ` | |
| <q-card-section> | |
| <p-progress | |
| :manager="modelValue" | |
| ></p-progress> | |
| </q-card-section> | |
| `, | |
| methods: { | |
| submit() { | |
| this.$emit('submit'); | |
| }, | |
| cancel() { | |
| this.$emit('cancel'); | |
| } | |
| }, | |
| watch: { | |
| // 全部进度满时自动关闭弹窗 | |
| modelValue: { | |
| handler(new_manager, old_manager) { | |
| const that = this; | |
| $AEL(new_manager, 'progress', e => { | |
| new_manager.steps > 0 && | |
| new_manager.finished === new_manager.steps && | |
| setTimeout(() => that.submit(), 3000); | |
| }); | |
| }, | |
| immediate: true, | |
| } | |
| }, | |
| } | |
| }, | |
| // 文库书籍搜索框 | |
| 'p-book-search': { | |
| data: { | |
| /** | |
| * @typedef {'name' | 'author' | 'aid' | 'link'} SearchType | |
| */ | |
| /** | |
| * @typedef {Object} SearchResultOption | |
| * @property {number} aid 书籍id | |
| * @property {string} name 书名 | |
| * @property {string} author 作者 | |
| */ | |
| /** @typedef {SearchResultOption & { command: string }} DisplayOption */ | |
| name: 'PBookSearch', | |
| props: ['modelValue', 'label'], | |
| emits: ['update:modelValue', 'submit', 'cancel'], | |
| template: ` | |
| <q-item tag="label" tabindex="-1"> | |
| <!-- 提示文本 --> | |
| <q-item-section class="col-3"> | |
| {{ label ?? ${ escJsStr(CONST.Text.Component.PBookSearch.Search) } }} | |
| </q-item-section> | |
| <!-- 搜索框 兼 搜索结果下拉列表 兼 搜索结果书籍选择器 --> | |
| <!-- 设计:在这个可输入的选择器中输入搜索内容后,将搜索结果书籍列表展示在下拉列表中,点击选择列表内容以确定选中书籍 --> | |
| <q-item-section class="col-6"> | |
| <q-select | |
| v-model="selected_option" | |
| :options="display_options" | |
| label=${ escJsStr(CONST.Text.Component.PBookSearch.Placeholder) } | |
| use-input | |
| ref="searchbox" | |
| @new-value="doSearch" | |
| @focus="e => inputmode = true" | |
| @blur="e => inputmode = false" | |
| > | |
| <!-- 选项插槽 --> | |
| <template v-slot:option="scope"> | |
| <!-- 常规书籍项 --> | |
| <q-item v-if="!Object.hasOwn(scope.opt, 'command')" v-bind="scope.itemProps"> | |
| <!-- 封面 --> | |
| <q-item-section avatar> | |
| <q-img :src="covers.get(scope.opt.aid)"></q-img> | |
| </q-item-section> | |
| <!-- 文本 --> | |
| <q-item-section> | |
| <!-- 书名 --> | |
| <q-item-label class="text-h6"> | |
| <a :href="urls.get(scope.opt.aid)" @click="preventLink"> | |
| {{ scope.opt.name }} | |
| </a> | |
| </q-item-label> | |
| <!-- 作者 --> | |
| <q-item-label caption> | |
| {{ scope.opt.author }} | |
| </q-item-label> | |
| </q-item-section> | |
| </q-item> | |
| <!-- 空列表占位项,展示提示UI --> | |
| <q-item v-else-if="scope.opt.command === 'placeholder'"> | |
| <q-item-section> | |
| <q-item-label> | |
| ${ CONST.Text.Component.PBookSearch.PlaceholderItem } | |
| </q-item-label> | |
| </q-item-section> | |
| </q-item> | |
| </template> | |
| <!-- 选中项插槽 --> | |
| <template v-slot:selected> | |
| <!-- 有选中项且处于输入状态:简洁模式仅展示书籍封面图 --> | |
| <q-img v-if="selected_option && inputmode" :src="covers.get(aid)" | |
| height="2em" | |
| width="2em" | |
| fit="contain" | |
| ></q-img> | |
| <!-- 有选中项且非输入状态:展示完整书籍信息QItem --> | |
| <q-item v-else-if="selected_option"> | |
| <!-- 封面 --> | |
| <q-item-section avatar> | |
| <q-img :src="covers.get(aid)"></q-img> | |
| </q-item-section> | |
| <!-- 文本 --> | |
| <q-item-section> | |
| <!-- 书名 --> | |
| <q-item-label class="text-h6"> | |
| <a :href="urls.get(aid)"> | |
| {{ aid_options.get(aid).name }} | |
| </a> | |
| </q-item-label> | |
| <!-- 作者 --> | |
| <q-item-label caption> | |
| {{ aid_options.get(aid).author }} | |
| </q-item-label> | |
| </q-item-section> | |
| </q-item> | |
| <!-- 无选中项:什么都不展示,留出最大空间以供输入 --> | |
| <!-- Nothing --> | |
| </template> | |
| </q-select> | |
| </q-item-section> | |
| <!-- 搜索类型选择器 --> | |
| <q-item-section class="col-3"> | |
| <q-select | |
| v-model="type" | |
| label=${ escJsStr(CONST.Text.Component.PBookSearch.SelectType) } | |
| :options="types" | |
| emit-value | |
| map-options | |
| ></q-select> | |
| </q-item-section> | |
| <q-item> | |
| `, | |
| data() { | |
| const PBookSearch = CONST.Text.Component.PBookSearch; | |
| return { | |
| /** | |
| * 搜索框v-model绑定值 | |
| * @type {string} | |
| */ | |
| search: '', | |
| /** | |
| * 类型选择器v-model绑定值 | |
| * @type {SearchType} | |
| */ | |
| type: 'name', | |
| /** | |
| * 搜索框搜索结果选项列表 | |
| * @type {SearchResultOption[]} | |
| */ | |
| result_options: [], | |
| /** | |
| * 类型选择器的类型选项列表 | |
| * @type {{ label: string, value: SearchType }[]} | |
| */ | |
| types: Object.freeze([{ | |
| label: PBookSearch.ByName, | |
| value: 'name' | |
| }, { | |
| label: PBookSearch.ByAuthor, | |
| value: 'author' | |
| }, { | |
| label: PBookSearch.ByID, | |
| value: 'aid' | |
| }, { | |
| label: PBookSearch.ByLink, | |
| value: 'link' | |
| }]), | |
| /** | |
| * 用户选中的书籍选项,通过watch此值和aid(组件v-model绑定值)实时挂钩 | |
| * @type {SearchResultOption | null} | |
| */ | |
| selected_option: null, | |
| /** | |
| * 是否处于输入状态,用于切换选择框选中项简洁展示模式 | |
| * 当处于输入状态时,仅展示书籍封面而非完整书籍QItem | |
| * @type {boolean} | |
| */ | |
| inputmode: false, | |
| }; | |
| }, | |
| methods: { | |
| /** | |
| * 当用户输入了一些内容并回车提交时执行,利用的是QSelect的new-value机制 | |
| * @param {string} text - 用户输入的值 | |
| * @param {(val: SearchResultOption, new_value_mode: 'add' | 'add-unique') => void} done - QSelect提供的向选项列表中添加新选项数据的函数 | |
| */ | |
| async doSearch(text, done) { | |
| // 1. 首先通过输入的值判断输入类型是否为aid/link,如果是就切换搜索类型选择器的值 | |
| // 2. 反向判断:输入的值如果不符合当前选择类型的要求(比如类型为aid但输入非数值),就不再继续 | |
| // 3. 根据类型执行: | |
| // (1) 若为搜索,则执行搜索;将搜索结果转化为选项数据替换原有数据 | |
| // (2) 若为aid或link,则api获取书籍信息;将书籍信息转化为选项数据替换原有数据 | |
| /** @type {api} */ | |
| const api = await require('api', true); | |
| /** @type {utils} */ | |
| const utils = await require('utils', true); | |
| const that = this; | |
| // 根据输入,自动切换为aid或link类型,并判断输入是否符合类型要求 | |
| if (/^\d+$/.test(text)) { | |
| that.type = 'aid'; | |
| } else if (that.type === 'aid') { | |
| return; | |
| } | |
| if (/^https?:\/\/www\.wenku8\.(net|cc)\/book\/\d+\.htm$/.test(text)) { | |
| that.type = 'link'; | |
| } else if (that.type === 'link') { | |
| return; | |
| } | |
| /** 搜索小说 */ | |
| const search = async () => { | |
| let xml; | |
| if (that.type === 'name') { | |
| xml = await api.searchNovelByNovelName({ | |
| search_key: text, | |
| lang: utils.getLanguage() | |
| }); | |
| } else { | |
| xml = await api.searchNovelByAuthorName({ | |
| search_key: text, | |
| lang: utils.getLanguage() | |
| }); | |
| } | |
| /** @type {SearchResultOption[]} */ | |
| const options = [...$All(xml, 'item')].map( | |
| item => ({ | |
| aid: parseInt(item.getAttribute('aid')), | |
| name: $(item, 'data[name="Title"]').firstChild.nodeValue, | |
| author: $(item, 'data[name="Author"]').getAttribute('value'), | |
| }) | |
| ); | |
| done(null); | |
| that.result_options = options; | |
| console.log('updated'); | |
| }; | |
| /** 直接加载某一本小说信息 */ | |
| const direct = async () => { | |
| let aid; | |
| if (that.type === 'aid') { | |
| aid = parseInt(text, 10); | |
| } else { | |
| aid = parseInt(text.match(/\/book\/(\d+)\.htm/)[1], 10); | |
| } | |
| const xml = await api.getNovelInfo({ aid, lang: utils.getLanguage() }); | |
| /** @type {SearchResultOption[]} */ | |
| const options = [{ | |
| aid, | |
| name: $(xml, 'data[name="Title"]').firstChild.nodeValue, | |
| author: $(xml, 'data[name="Author"]').getAttribute('value'), | |
| }]; | |
| done(null); | |
| that.result_options = options; | |
| }; | |
| switch (that.type) { | |
| case 'name': { | |
| await search(); | |
| break; | |
| } | |
| case 'author': { | |
| await search(); | |
| break; | |
| } | |
| case 'aid': { | |
| await direct(); | |
| break; | |
| } | |
| case 'link': { | |
| await direct(); | |
| break; | |
| } | |
| } | |
| // 加载完成后,确保列表展开 | |
| this.$refs.searchbox?.showPopup(); | |
| }, | |
| /** | |
| * 当点击列表项/选中项的链接时执行,阻止页面简单点击跳转,允许按下辅助键(如Ctrl)时跳转 | |
| * @param {PointerEvent} e | |
| */ | |
| preventLink(e) { | |
| const key_pressed = e.ctrlKey || e.shiftKey || e.metaKey || e.altKey; | |
| key_pressed || e.preventDefault(); | |
| } | |
| }, | |
| watch: { | |
| selected_option: { | |
| /** | |
| * 监听选中项并自动同步到aid(组件v-model绑定值) | |
| * @param {SearchResultOption | null} new_val | |
| * @param {SearchResultOption | null} old_val | |
| */ | |
| handler(new_val, old_val) { | |
| if (new_val) { | |
| this.aid = new_val.aid; | |
| } else { | |
| this.aid = null; | |
| } | |
| }, | |
| immediate: true, | |
| } | |
| }, | |
| computed: { | |
| /** | |
| * 实际最终传递给QSelect、显示在UI中的选项列表 | |
| * 当result_options不为空时就是result_options,当result_options为空时添加一个占位提示项 | |
| * @returns {DisplayOption[]} | |
| */ | |
| display_options() { | |
| /** @type {SearchResultOption[]} */ | |
| const result_options = this.result_options; | |
| /** @type {DisplayOption[]} */ | |
| const placeholder = [{ | |
| aid: 0, | |
| author: '', | |
| name: '', | |
| command: 'placeholder', | |
| }]; | |
| return result_options.length ? result_options : placeholder; | |
| }, | |
| /** | |
| * 搜索结果封面图对象 | |
| * @returns {Map<number, string>} | |
| */ | |
| covers() { | |
| /** @type {SearchResultOption[]} */ | |
| const result = this.result_options; | |
| const map = new Map(); | |
| result.forEach(book => map.set( | |
| book.aid, `http://img.wenku8.com/image/${ Math.floor(book.aid / 1000) }/${ book.aid }/${ book.aid }s.jpg` | |
| )); | |
| return map; | |
| }, | |
| /** | |
| * 搜索结果链接对象 | |
| * @returns {Map<number, string>} | |
| */ | |
| urls() { | |
| /** @type {SearchResultOption[]} */ | |
| const result = this.result_options; | |
| const map = new Map(); | |
| console.log(result); | |
| result.forEach(book => map.set( | |
| book.aid, `http://${ location.host }/book/${ book.aid }.htm` | |
| )); | |
| return map; | |
| }, | |
| /** | |
| * aid反查搜索结果选项对象 | |
| * @returns {Map<number, SearchResultOption>} | |
| */ | |
| aid_options() { | |
| /** @type {SearchResultOption[]} */ | |
| const options = this.result_options; | |
| const map = new Map(); | |
| options.forEach(book => map.set( | |
| book.aid, book | |
| )); | |
| return map; | |
| }, | |
| /** | |
| * 组件v-model绑定值 | |
| * 书籍aid,用户未选择书籍时为null | |
| * @type {number | null} | |
| */ | |
| aid: { | |
| get() { | |
| return this.modelValue; | |
| }, | |
| set(val) { | |
| this.$emit('update:modelValue', val); | |
| }, | |
| } | |
| } | |
| }, | |
| }, | |
| }; | |
| /** @typedef {keyof typeof components} ComponentID */ | |
| /** | |
| * 注册预实现的自定义组件到Vue app | |
| * @param {Object} app - 需要注册组件的Vue app | |
| * @param {ComponentID | ComponentID[]} component_ids - 需要注册的组件的ID,同时也是组件名称,可以同时注册多个组件 | |
| * @param {ComponentID[]} _registered - 内部保留参数,用于在注册依赖组件时防止重复注册已注册组件 | |
| */ | |
| function register(app, component_ids, _registered = []) { | |
| Array.isArray(component_ids) || (component_ids = [component_ids]); | |
| component_ids.forEach(id => { | |
| Assert(Object.hasOwn(components, id), `component.register: unrecognized component id ${ escJsStr(id) }`, TypeError); | |
| // 注册组件的依赖组件 | |
| /** @type {{ data: Object, dependencies: undefined | ComponentID | ComponentID[] }} */ | |
| const com = components[id]; | |
| let deps = com.dependencies ?? []; | |
| Array.isArray(deps) || (deps = [deps]); | |
| deps.forEach(d => _registered.includes(d) || register(app, d, _registered)); | |
| // 注册组件自身 | |
| app.component(id, com.data); | |
| _registered.push(id); | |
| }); | |
| } | |
| /** | |
| * 创建一个Quasar Dialog弹窗展示某预注册的组件 | |
| * 接受子组件触发的以下事件: | |
| * - submit: *REQUIRED* 关闭弹窗并resolve当前组件model值,通常用于用户确认输入时 | |
| * - cancel: *Optional* 关闭弹窗并resolve为null,通常用于用户取消输入时 | |
| * @param {ComponentID} component_id - 需要展示的组件ID | |
| * @param {Object} options - 一些弹窗配置 | |
| * @param {boolean} [options.value=null] - 组件model的初始值 | |
| * @param {boolean} [options.seamless=false] - 无缝模式:不使用遮罩,因此用户也可以与页面的其他部分进行交互;默认为false | |
| * @param {boolean} [options.persistent=false] - 设置后,用户在对话框外单击或按 ESC 键时不再关闭对话框;此外,应用程序路由更改也不会关闭它 | |
| * @param {'standard' | 'left' | 'right' | 'top' | 'bottom'} [options.position='standard'] - 将对话框附着到一侧(顶部、右侧、底部或左侧) | |
| * @param {Object} [options.props={}] - 需要传递给内部预注册组件的属性,如 { class: "myclass" }等等;此值必须可通过{@link structuredClone}深度克隆 | |
| * @returns {Promise<any>} 弹窗关闭时,组件的model值 | |
| */ | |
| function dialog(component_id, { | |
| value = null, | |
| seamless = false, | |
| persistent = false, | |
| position = 'standard', | |
| props = {}, | |
| } = {}) { | |
| const { promise, reject, resolve } = Promise.withResolvers(); | |
| const container = $CrE('div'); | |
| container.innerHTML = ` | |
| <q-dialog | |
| :seamless="seamless" | |
| :persistent="persistent" | |
| :position="position" | |
| v-model="visible" | |
| @hide="onHide" | |
| > | |
| <q-card> | |
| <${ component_id } | |
| v-model="value" | |
| v-bind="props" | |
| @submit="onSubmit" | |
| @cancel="onCancel" | |
| ></${ component_id }> | |
| </q-card> | |
| </q-dialog> | |
| `; | |
| document.body.append(container); | |
| const app = Vue.createApp({ | |
| data() { | |
| return { | |
| value, | |
| seamless, | |
| persistent, | |
| position, | |
| visible: true, | |
| position, | |
| props: Object.freeze(structuredClone(props)) | |
| }; | |
| }, | |
| methods: { | |
| onSubmit() { | |
| resolve(this.value); | |
| this.visible = false; | |
| }, | |
| onCancel() { | |
| resolve(null); | |
| this.visible = false; | |
| }, | |
| onHide() { | |
| resolve(null); | |
| app.unmount(); | |
| container.remove(); | |
| }, | |
| }, | |
| }); | |
| register(app, component_id); | |
| app.use(Quasar); | |
| app.mount(container); | |
| return promise; | |
| } | |
| return { register, dialog }; | |
| } | |
| }, | |
| settings: { | |
| desc: '分组展示的设置界面(仅界面UI)', | |
| dependencies: ['dependencies', 'debugging', 'component'], | |
| params: ['GM_setValue', 'GM_getValue'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.settings.func>>} settings */ | |
| async func(GM_setValue, GM_getValue) { | |
| /** @type {debugging} */ | |
| const debugging = require('debugging'); | |
| /** @type {component} */ | |
| const component = require('component'); | |
| /** | |
| * 代表一个设置组,同一设置组内的设置将会显示在同一板块/标签页中 | |
| * @typedef {Object} SettingsGroup | |
| * @property {SettingItem[]} items - 组内全部设置项 | |
| * @property {string} label - 组名称,用于在UI中展示 | |
| * @property {string} id - 组id标识,全局唯一 | |
| */ | |
| /** | |
| * 代表一条设置项 | |
| * @typedef {Object} SettingItem | |
| * @property {string} label - 设置项名称 | |
| * @property {string} type - 设置项类型 | |
| * @property {string | null} [caption] - 设置项副标题,可以省略;为假值时不渲染副标题元素 | |
| * @property {string} key - 用作settings 读/写对象的prop名称,也用作v-model值 | |
| * @property {string} [help] - 在用户编辑此项设置时,显示的帮助文档 | |
| * @property {boolean | 'page'} [reload] - 修改设置后是否需要重载页面才能生效,false: 实时生效,true: 需要重载,'page': 其他页面需要重载;默认为false | |
| * @property {{label: string, value: string}[]} [options] - select类型设置的options | |
| * @property {{min: number, max: number, step: number}} [range] - 滑块类型的最小/最大值、步长 | |
| * @property {function} [callback] - button类型设置项的按钮回调;以及其他任何类型的设置值在当前页面的UI中被改变的回调 | |
| * @property {string} [button_label] - button类型设置项的按钮文本 | |
| * @property {string} [button_icon] - button类型设置项的按钮图标 | |
| * @property {getter} get - 需要显示设置内容到UI中时,实际执行读取设置操作的函数 | |
| * @property {setter} set - 用户在UI中更改设置时,实际执行保存设置操作的函数 | |
| */ | |
| /** | |
| * 用户在UI中更改设置时,实际执行保存设置操作的函数 | |
| * @callback setter | |
| * @param {any} val | |
| */ | |
| /** | |
| * 需要显示设置内容到UI中时,实际执行读取设置操作的函数 | |
| * @callback getter | |
| * @returns {any} | |
| */ | |
| // 创建UI | |
| const Settings = CONST.Text.Settings; | |
| const container = $CrE('div'); | |
| container.innerHTML = ` | |
| <q-dialog v-model="visible" full-width full-height class="plus-settings"> | |
| <q-layout container view="hHh Lpr fFf"> | |
| <q-header bordered> | |
| <q-toolbar> | |
| <q-btn flat round icon="menu" style="background: transparent;" @click="$refs.drawer.toggle()"></q-btn> | |
| <q-toolbar-title>${ Settings.DialogTitle }</q-toolbar-title> | |
| <q-btn flat round icon="close" style="background: transparent;" @click="visible = false"></q-btn> | |
| </q-toolbar> | |
| <q-tabs | |
| align="left" | |
| v-model="header_tab" | |
| > | |
| <q-tab | |
| name="settings" | |
| label="${ Settings.Tabs.ModuleSettings }" | |
| ></q-tab> | |
| <q-tab | |
| name="about" | |
| label="${ Settings.Tabs.About }" | |
| ></q-tab> | |
| </q-tabs> | |
| </q-header> | |
| <q-drawer | |
| show-if-above | |
| bordered | |
| side="left" | |
| :breakpoint="drawer_breakpoint" | |
| ref="drawer" | |
| > | |
| <!-- 根据header tab值确定drawer内容 --> | |
| <q-tab-panels v-model="header_tab"> | |
| <q-tab-panel name="settings" class="q-pa-none"> | |
| <q-tabs | |
| v-model="tab" | |
| indicator-color="primary" | |
| active-bg-color="active" | |
| vertical | |
| > | |
| <q-tab v-for="group of groups" | |
| no-caps | |
| :name="group.id" | |
| :label="group.label" | |
| ></q-tab> | |
| </q-tabs> | |
| </q-tab-panel> | |
| <q-tab-panel name="about" class="q-pa-none"> | |
| <q-tabs | |
| v-model="about_tab" | |
| indicator-color="primary" | |
| active-bg-color="active" | |
| vertical | |
| > | |
| <q-tab | |
| no-caps | |
| name="about" | |
| label="${ Settings.Tabs.AboutTab }" | |
| ></q-tab> | |
| <q-tab | |
| no-caps | |
| name="faq" | |
| label="${ Settings.Tabs.FAQ }" | |
| ></q-tab> | |
| </q-tabs> | |
| </q-tab-panel> | |
| </q-tab-panels> | |
| </q-drawer> | |
| <q-page-container> | |
| <q-page> | |
| <q-card square class="settings-container q-pa-md"> | |
| <q-tab-panels v-model="header_tab"> | |
| <!-- "设置"选项卡:设置项列表 --> | |
| <q-tab-panel name="settings" class="q-pa-none"> | |
| <q-list v-if="header_tab === 'settings'"> | |
| <q-item v-if="current_group" v-for="item of current_group.items" tag="label"> | |
| <q-item-section> | |
| <q-item-label>{{ item.label }}</q-item-label> | |
| <q-item-label caption v-if="item.caption">{{ item.caption }}</q-item-label> | |
| <q-item-label caption v-if="item.reload === true && modified[item.key]" class="text-warning">${ CONST.Text.Settings.NeedsReload }</q-item-label> | |
| <q-item-label caption v-if="item.reload === 'page' && modified[item.key]" class="text-warning">${ CONST.Text.Settings.OtherPageNeedsReload }</q-item-label> | |
| </q-item-section> | |
| <q-item-section avatar> | |
| <!-- 布尔值类型: 开关 --> | |
| <q-toggle v-if="item.type === 'boolean'" | |
| color="primary" | |
| v-model="settings[item.key]" | |
| @update:model-value="val => onSettingUpdate(item, val)" | |
| ></q-toggle> | |
| <!-- 字符串类型: 输入框 --> | |
| <q-input v-else-if="item.type === 'string'" | |
| v-model="settings[item.key]" | |
| @focus="tooltips[item.key] = true" | |
| @blur="tooltips[item.key] = false" | |
| @keydown="e => e.stopPropagation()" | |
| @update:model-value="val => onSettingUpdate(item, val)" | |
| ></q-input> | |
| <!-- 浮点数类型: 输入框 --> | |
| <p-number v-else-if="item.type === 'number'" | |
| v-model="settings[item.key]" | |
| @focus="tooltips[item.key] = true" | |
| @blur="tooltips[item.key] = false" | |
| @keydown="e => e.stopPropagation()" | |
| @update:model-value="val => onSettingUpdate(item, val)" | |
| ></p-number> | |
| <!-- 数字范围类型:滑块 --> | |
| <q-slider v-else-if="item.type === 'range'" | |
| :max="item.range.max" | |
| :min="item.range.min" | |
| :step="item.range.step" | |
| style="width: 10em;" | |
| v-model="settings[item.key]" | |
| @update:model-value="val => onSettingUpdate(item, val)" | |
| ></q-slider> | |
| <!-- select类型: 选择器 --> | |
| <q-select v-else-if="item.type === 'select'" | |
| :options="item.options" | |
| v-model="settings[item.key]" emit-value map-options | |
| @update:model-value="val => onSettingUpdate(item, val)" | |
| ></q-select> | |
| <!-- choose类型: 单选(更复杂的选择器) --> | |
| <p-choose v-else-if="item.type === 'choose'" | |
| :options="item.options" | |
| v-model="settings[item.key]" | |
| @update:model-value="val => onSettingUpdate(item, val)" | |
| ></p-choose> | |
| <!-- 颜色类型: 颜色选择器 --> | |
| <p-color v-else-if="item.type === 'color'" | |
| v-model="settings[item.key]" | |
| @update:model-value="val => onSettingUpdate(item, val)" | |
| ></p-color> | |
| <!-- 本地图片类型: 本地图片选择器 --> | |
| <p-image-select v-else-if="item.type === 'image'" | |
| v-model="settings[item.key]" | |
| @update:model-value="val => onSettingUpdate(item, val)" | |
| ></p-image-select> | |
| <!-- 按钮类型: 按钮 --> | |
| <q-btn v-else-if="item.type === 'button'" | |
| :label="item.button_label" | |
| :icon="item.button_icon" | |
| @click="item.callback" | |
| flat | |
| ></q-btn> | |
| <span v-else>Warning: item.type invalid ({{ item.type }})</span> | |
| <!-- 浮动提示 --> | |
| <q-tooltip v-if="item.help" | |
| v-model="tooltips[item.key]" | |
| :no-parent-event="item.type === 'string'" | |
| v-html="item.help" | |
| style="font-size: 1em;" | |
| ></q-tooltip> | |
| </q-item-section> | |
| </q-item> | |
| </q-list> | |
| </q-tab-panel> | |
| <!-- "关于"选项卡 --> | |
| <q-tab-panel name="about" class="q-pa-none text-body1"> | |
| <q-tab-panels v-model="about_tab"> | |
| <!-- 关于 --> | |
| <q-tab-panel name="about" class="q-pa-none"> | |
| <div class="text-h5 q-mb-md">${ GM_info.script.name }</div> | |
| <div class="text-subtitle1 q-my-sm">${ GM_info.script.description }</div> | |
| <div class="q-my-sm">${ Settings.About.Version }</div> | |
| <div class="q-my-sm">${ Settings.About.Author }</div> | |
| <div class="q-my-sm">${ Settings.About.Homepage }</div> | |
| <div class="q-my-sm"> | |
| ${ Settings.About.TechnicalNote } | |
| <span class="text-weight-bold" style="cursor: pointer;" @click="cool">Cool!</span> | |
| </div> | |
| </q-tab-panel> | |
| <!-- 常见问题 --> | |
| <q-tab-panel name="faq" class="q-pa-none"> | |
| <q-expansion-item | |
| v-for="faq of FAQ" | |
| :label="faq.Q" | |
| class="text-h6" | |
| > | |
| <div class="text-body1 q-pa-md">{{ faq.A }}</div> | |
| </q-expansion-item> | |
| </q-tab-panel> | |
| </q-tab-panels> | |
| </q-tab-panel> | |
| </q-tab-panels> | |
| </q-card> | |
| </q-page> | |
| </q-page-container> | |
| </q-layout> | |
| </q-dialog> | |
| `; | |
| let instance; | |
| const app = Vue.createApp({ | |
| data() { | |
| return { | |
| /** | |
| * 存储设置项信息 | |
| * @type {SettingsGroup[]} | |
| */ | |
| groups: [], | |
| tab: '', | |
| header_tab: 'settings', | |
| about_tab: 'about', | |
| visible: false, | |
| /** | |
| * 存储全部设置内容的变量 | |
| * @type {Record<string, Record<string, any>>} | |
| */ | |
| all_settings: {}, | |
| /** | |
| * 记录设置项自从设置界面创建起,是否被修改过的变量 | |
| * @type {Record<string, Record<string, boolean>>} | |
| */ | |
| all_modified: {}, | |
| FAQ: Settings.About.FAQ, | |
| }; | |
| }, | |
| computed: { | |
| /** @type {SettingsGroup} */ | |
| current_group() { | |
| return this.groups.find(g => g.id === this.tab); | |
| }, | |
| /** | |
| * 读写当前UI上的active tab对应的group的设置 | |
| * @type {Record<string, any>} | |
| */ | |
| settings() { | |
| return this.all_settings[this.tab]; | |
| }, | |
| modified() { | |
| return this.all_modified[this.tab]; | |
| }, | |
| /** | |
| * 当前UI上的active tab对应的group的key - help文档对照表对象 | |
| * @type {Record<string, string>} | |
| */ | |
| tooltips() { | |
| return this.current_group.items.reduce((tips, item) => { | |
| tips[item.key] = item.help; | |
| return tips; | |
| }, {}); | |
| }, | |
| drawer_breakpoint() { | |
| return debugging.script_debug ? 880 : 1023; | |
| }, | |
| }, | |
| watch: { | |
| // 监听设置组变化 | |
| groups: { | |
| async handler(val, old_val) { | |
| // 当从没有设置组到有一个设置组加入时,自动将此设置组设为active tag | |
| if (val.length && !this.tab) { | |
| this.tab = val[0].id; | |
| } | |
| // 自动将新加入的设置组加入到this.all_settings和this.all_modified中 | |
| for (const group of val) { | |
| // 无论是否已有此组都强制更新此组,因为组内设置项可能变化 | |
| const setting = {}; | |
| await Promise.all(group.items.map(async item => { | |
| item.get && (setting[item.key] = await Promise.resolve(item.get())); | |
| return setting; | |
| })); | |
| this.all_settings[group.id] = setting; | |
| this.all_modified[group.id] = group.items.reduce((modified, item) => { | |
| modified[item.key] = false; | |
| return modified; | |
| }, {}); | |
| } | |
| }, | |
| deep: true, | |
| }, | |
| }, | |
| methods: { | |
| /** | |
| * @param {SettingItem} item | |
| * @param {any} val | |
| */ | |
| async onSettingUpdate(item, val) { | |
| // 回调外部setter,保存设置 | |
| await Promise.resolve(item.set(val)); | |
| // 如果有callback,回调callback | |
| if (item.callback) { | |
| await Promise.resolve(item.callback()); | |
| } | |
| // 记录此项已被修改过 | |
| this.modified[item.key] = true; | |
| }, | |
| /** | |
| * Make some confetti. Congratulations! | |
| */ | |
| cool() { | |
| let count = 200; | |
| let defaults = { | |
| origin: { y: 0.7 }, | |
| zIndex: 8000, | |
| }; | |
| function fire(particleRatio, opts) { | |
| confetti({ | |
| ...defaults, | |
| ...opts, | |
| particleCount: Math.floor(count * particleRatio), | |
| }); | |
| } | |
| fire(0.25, { | |
| spread: 26, | |
| startVelocity: 55, | |
| }); | |
| fire(0.2, { | |
| spread: 60, | |
| }); | |
| fire(0.35, { | |
| spread: 100, | |
| decay: 0.91, | |
| scalar: 0.8, | |
| }); | |
| fire(0.1, { | |
| spread: 120, | |
| startVelocity: 25, | |
| decay: 0.92, | |
| scalar: 1.2, | |
| }); | |
| fire(0.1, { | |
| spread: 120, | |
| startVelocity: 45, | |
| }); | |
| }, | |
| }, | |
| mounted() { | |
| instance = this; | |
| }, | |
| }); | |
| document.body.append(container); | |
| app.use(Quasar); | |
| // 注册自定义设置项表单组件 | |
| component.register(app, ['p-image-select', 'p-color', 'p-choose', 'p-number']); | |
| // 挂载Vue | |
| app.mount(container); | |
| // 设置界面样式 | |
| addStyle(` | |
| .plus-settings .settings-container { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| `); | |
| // 注册侧边栏设置按钮 | |
| require('sidepanel', true).then( | |
| /** @param {sidepanel} sidepanel */ | |
| sidepanel => sidepanel.registerButton({ | |
| id: 'settings.show', | |
| label: CONST.Text.Settings.DialogTitle, | |
| icon: 'settings', | |
| type: 'universal', | |
| index: -4, | |
| callback() { instance.visible = true; } | |
| }) | |
| ); | |
| /** | |
| * 注册新的设置组 | |
| * @param {SettingsGroup} group - 设置组对象 | |
| */ | |
| function registerGroup({ id, label, items = [] }) { | |
| /** @type {SettingsGroup[]} */ | |
| const groups = instance.groups; | |
| Assert(groups.every(g => g.id !== id), `duplicate id ${escJsStr(id)}`, TypeError); | |
| groups.push({ id, label, items }); | |
| } | |
| /** | |
| * 注册新的设置项 | |
| * @param {string} id - 设置组id | |
| * @param {SettingItem | SettingItem[]} items | |
| */ | |
| function registerSettings(id, items) { | |
| items = Array.isArray(items) ? items : [items]; | |
| /** @type {SettingsGroup[]} */ | |
| const groups = instance.groups; | |
| const group = groups.find(g => g.id === id); | |
| Assert(group, `Settings group with id ${escJsStr(id)} not exist, call registerGroup first.`, TypeError); | |
| group.items.push(...items); | |
| } | |
| /** | |
| * 主动更新设置项的值到设置UI中 | |
| * @param {string} group_id - 设置组id | |
| * @param {string} item_key - 设置项key | |
| * @param {any} val - 设置项的新值 | |
| */ | |
| function update(group_id, item_key, val) { | |
| instance.all_settings[group_id][item_key] = val; | |
| } | |
| return { | |
| registerGroup, registerSettings, | |
| update, | |
| /** 用于导出JSDoc类型,无实际作用 */ | |
| _types: { | |
| /** @type {SettingItem} */ | |
| SettingItem: {}, | |
| /** @type {SettingsGroup} */ | |
| SettingsGroup: {}, | |
| }, | |
| }; | |
| } | |
| }, | |
| configs: { | |
| desc: '模块配置管理器,对settings和脚本存储空间的高级封装;分模块管理配置存储与设置界面,跨页面实例同步配置、功能与设置界面;负责模块的 设置界面 - 设置存储 - 模块功能 间的统一调度', | |
| dependencies: ['settings'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.configs.func>>} configs */ | |
| async func() { | |
| /** @type {settings} */ | |
| const settings = require('settings'); | |
| /** @typedef {typeof settings._types.SettingItem} SettingItem */ | |
| /** @typedef {typeof settings._types.SettingsGroup} SettingsGroup */ | |
| /** | |
| * 模块监听器函数 | |
| * @callback update_callback | |
| * @param {string} key - 设置项key | |
| * @param {any} old_val - 设置项旧值 | |
| * @param {any} new_val - 设置项新值 | |
| * @param {boolean} remote - 表示本次更改是否来源于另一页面的脚本实例 | |
| */ | |
| /** | |
| * 代表模块监听器的对象 | |
| * @typedef {{ id: symbol, callback: update_callback }} config_listener | |
| */ | |
| /** | |
| * 代表一个模块的配置 | |
| * @typedef {Object} Config | |
| * @property {string} id - 全局唯一模块id | |
| * @property {typeof GM_addValueChangeListener || null} GM_addValueChangeListener - 用于监听设置项内容变化的GM函数 | |
| * @property {SettingItem[]} items - 注册到settings界面中的设置项数组 | |
| * @property {string} label - 显示在settings界面中的模块名称 | |
| * @property {Record<string, config_listener[]>} listeners - 监听模块设置内容变化的全部监听器 | |
| */ | |
| /** @type {Record<string, Config>} */ | |
| const configs = {}; | |
| /** | |
| * 注册一个新模块 | |
| * 为模块提供以下功能: | |
| * - 注册设置项到settings界面中 | |
| * - 在跨页面跨实例的配置存储更新中: | |
| * - 提供更新回调接口,以供模块将更改应用于实际功能 | |
| * - 自动将新配置值同步到settings界面中 | |
| * @param {string} id - 全局唯一模块id | |
| * @param {Object} options | |
| * @param {typeof GM_addValueChangeListener} [options.GM_addValueChangeListener] - 用于监听设置项内容变化的GM函数 | |
| * @param {SettingItem | SettingItem[]} [options.items=[]] - 注册到settings界面中的设置项数组 | |
| * @param {string} options.label - 显示在settings界面中的模块名称 | |
| * @param {Record<string, update_callback[]> | update_callback} [options.listeners={}] - 监听设置值变化的监听器, 可以为一个key-listener格式的对象用于分别监听多个设置项,也可以为一个listener函数用于监听全部模块设置项变化 | |
| */ | |
| function registerConfig(id, { | |
| GM_addValueChangeListener = null, | |
| items = [], | |
| label, | |
| listeners = {}, | |
| }) { | |
| // 记录此模块 | |
| const config = configs[id] = { | |
| id, | |
| items, | |
| label, | |
| listeners: {}, | |
| GM_addValueChangeListener, | |
| }; | |
| // 注册设置项 | |
| items = Array.isArray(items) ? items : [items]; | |
| settings.registerGroup({ id, label }); | |
| registerSettings(id, items); | |
| // 注册监听器 | |
| registerUpdateCallback(id, listeners); | |
| } | |
| /** | |
| * 注册设置项: | |
| * - 注册到settings界面中 | |
| * - 为每个设置项自动监听变化: | |
| * - 自动同步到设置界面中 | |
| * - 执行回调 | |
| * @param {string} id - 全局唯一模块id | |
| * @param {SettingItem | SettingItem[]} items - 需注册的设置项 | |
| * @param {typeof GM_addValueChangeListener} [GM_addValueChangeListener] - 本次注册的设置项,监听其值变化时所使用的GM函数,如不提供则使用模块注册时提供的GM函数 | |
| */ | |
| function registerSettings(id, items=[], GM_addValueChangeListener=null) { | |
| items = Array.isArray(items) ? items : [items]; | |
| const config = configs[id]; | |
| // 注册设置UI | |
| settings.registerSettings(id, items); | |
| // 用于监听设置项变化的GM函数 | |
| GM_addValueChangeListener = | |
| GM_addValueChangeListener ?? | |
| config.GM_addValueChangeListener ?? null; | |
| // 此次调用和此前注册中,至少要提供一个GM_addValueChangeListener,否则无法监听设置项内容变化 | |
| Assert(GM_addValueChangeListener, 'configs.registerSettings: GM_addValueChangeListener not provided when adding value change listeners'); | |
| // 监听每个设置项内容变化 | |
| items.forEach(item => { | |
| // 创建此设置项的监听器数组 | |
| config.listeners[item.key] = []; | |
| // 监听设置项内容变化 | |
| GM_addValueChangeListener( | |
| item.key, (key, old_val, new_val, remote) => { | |
| // 同步到设置UI | |
| settings.update(id, key, new_val); | |
| // 模块回调 | |
| configs[id].listeners[key].forEach(cb => cb.callback(key, old_val, new_val, remote)); | |
| } | |
| ); | |
| }); | |
| } | |
| /** | |
| * 注册设置内容更新回调 | |
| * @param {string} id - 监听目标模块id | |
| * @param {Record<string, update_callback> | update_callback} listener - 回调函数,可以为一个key-listener格式的对象用于分别监听多个设置项,也可以为一个listener函数用于监听全部模块设置项变化 | |
| * @returns {() => void} 用于取消回调的方法,调用后不再监听本次注册的所有相关设置项内容更新 | |
| */ | |
| function registerUpdateCallback(id, callback) { | |
| const config = configs[id]; | |
| if (typeof callback === 'function') { | |
| const unregisters = Reflect.ownKeys(config.listeners).map(key => register(key, callback)); | |
| return () => unregisters.forEach(unregister => unregister()); | |
| } else { | |
| const unregisters = Object.entries(callback).map(([key, callback]) => register(key, callback)); | |
| return () => unregisters.forEach(unregister => unregister()); | |
| } | |
| /** | |
| * 对模块内的一项设置注册内容更新回调 | |
| * @param {string} key | |
| * @param {update_callback} callback | |
| * @returns {() => void} 用于取消回调的方法,调用后不再监听内容更新 | |
| */ | |
| function register(key, callback) { | |
| const callback_id = Symbol('Configs.UpdateCallbackId'); | |
| config.listeners[key].push({ | |
| id: callback_id, callback | |
| }); | |
| return () => config.listeners[key].splice( | |
| config.listeners[key].findIndex(cb => cb.id === callback_id), | |
| 1 | |
| ) | |
| } | |
| } | |
| return { | |
| registerConfig, registerSettings, registerUpdateCallback, | |
| }; | |
| }, | |
| }, | |
| storageupdater: { | |
| desc: '管理和更新模块以及脚本存储', | |
| dependencies: ['debugging'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.storageupdater.func>>} storageupdater */ | |
| func() { | |
| /** @type {debugging} */ | |
| const debugging = require('debugging'); | |
| /** | |
| * 执行更新的函数,接受旧版存储作为参数,返回更新后的新版存储 | |
| * @callback updater | |
| * @param {Object} config | |
| * @returns {Object} | |
| */ | |
| /** | |
| * 脚本管理器提供的GM_*存储函数 | |
| * @typedef {Object} GM_funcs | |
| * @property {(key: string, defaultValue: any) => any} GM_getValue | |
| * @property {(key: string, value: any) => void} GM_setValue | |
| * @property {() => string[]} GM_listValues | |
| * @property {(key: string) => void} GM_deleteValue | |
| */ | |
| /** | |
| * 根据提供的更新器函数和当前存储的值更新存储值到最新版本 | |
| * 当未设置版本号时,默认版本号为0 | |
| * @param {updater[]} updaters - 更新函数数组,第0个函数为从第0版更新到第1版的更新器,第1个函数为从第1版更新到第2版的更新器,以此类推 | |
| * @param {GM_funcs} GM_funcs - 用于操作存储空间的GM_*存储函数,其中GM_getValue可以为带有默认值的utils.defaultedGet返回值函数 | |
| * @param {string} version_key - 存储版本号的键,默认为"config_version" | |
| */ | |
| function update(updaters, GM_funcs, version_key='config_version') { | |
| const { GM_getValue, GM_setValue, GM_listValues, GM_deleteValue } = GM_funcs; | |
| const max_version = updaters.length; | |
| // 注意:由于GM_getValue函数可能包含默认值逻辑,因此要仔细判断当前版本号 | |
| let cur_version = GM_listValues().length ? | |
| // 如果存储中已有内容,则严格以存储的版本号为准;没有存储版本号时,则认为版本号为0 | |
| GM_getValue(version_key, null) ?? 0 : | |
| // 如果存储为空,则允许使用默认值中的版本号;如果默认值也没有版本号,则认为版本号为0 | |
| GM_getValue(version_key) ?? 0; | |
| for (; cur_version < max_version; cur_version++) { | |
| // 获取当前存储和updater | |
| const updater = updaters[cur_version]; | |
| const storage = getStorageObj(GM_funcs); | |
| // 执行updater,若出现错误则停止更新流程 | |
| let has_error = false; | |
| const updated_storage = debugging.callWithErrorHandling(updater, null, [storage], err => { | |
| has_error = true; | |
| }); | |
| if (has_error) { break; } | |
| updated_storage[version_key] = cur_version + 1; | |
| // 本轮更新完成,存储更新结果 | |
| applyStorageObj(updated_storage); | |
| } | |
| function getStorageObj(GM_funcs) { | |
| const { GM_getValue, GM_listValues } = GM_funcs; | |
| return GM_listValues().reduce((obj, key) => Object.assign(obj, { [key]: GM_getValue(key) }), {}); | |
| } | |
| function applyStorageObj(storage) { | |
| const { GM_getValue, GM_setValue, GM_listValues, GM_deleteValue } = GM_funcs; | |
| GM_listValues().forEach(key => GM_deleteValue(key)); | |
| Object.entries(storage).forEach(([key, val]) => GM_setValue(key, val)); | |
| } | |
| } | |
| return { update }; | |
| } | |
| }, | |
| _styling: { | |
| desc: '文库网页样式管理器', | |
| disabled: true, | |
| detectDom: ['head', 'body'], | |
| dependencies: ['utils', 'configs'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {configs} */ | |
| const configs = require('configs'); | |
| // 带默认值的GM_getValue | |
| GM_getValue = utils.defaultedGet({ | |
| enabled: false, | |
| theme: 'darkmode', | |
| /** | |
| * 存储用户自定义主题 | |
| * @type {Record<string, string>} | |
| */ | |
| themes: {}, | |
| }, GM_getValue); | |
| /** @typedef {typeof FunctionLoader._types.checker} checker */ | |
| /** @typedef {{ checkers: [checker | checker[]], content: string }} Style */ | |
| /** | |
| * 将主题色应用到页面的CSS | |
| * @type {Record<string, Style>} | |
| */ | |
| const Styles = { | |
| block: { | |
| content: ` | |
| /* 标题、内容和脚注 */ | |
| .plus-styled .blocktitle { | |
| border-color: var(--plus-background-title); | |
| } | |
| .plus-styled :is(#left, #right, #centers, *) .blocktitle>:is(.txt, .txtr) { | |
| background-color: var(--plus-background-3); | |
| line-height: 27px; | |
| padding-top: 0; | |
| } | |
| .plus-styled :is(#left, #right, *) .blockcontent { | |
| background-color: var(--plus-background-1) | |
| } | |
| .plus-styled :is(#left, #right, *) .blocknote { | |
| background-color: var(--plus-background-2); | |
| } | |
| /* 特定类型内容 */ | |
| .plus-styled :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *, .ultop li) { | |
| color: var(--plus-text-title) | |
| } | |
| /* 边框 */ | |
| .plus-styled .block { | |
| border: 1px solid var(--plus-primary); | |
| } | |
| .plus-styled :is(.blockcontent, .blocknote) { | |
| border-color: var(--plus-primary); | |
| } | |
| .plus-styled .block :is(.ultop li, .ultops li) { | |
| border-bottom: 1px dashed var(--plus-primary); | |
| } | |
| `, | |
| }, | |
| book: { | |
| checkers: [{ | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, { | |
| type: 'regpath', | |
| value: /\/modules\/article\/articleinfo\.php/ | |
| }], | |
| content: ` | |
| /* 需要补充基层颜色的各区域 */ | |
| .plus-styled :is(:is(#left, #right, #centers) .blockcontent, .blockcontent, .odd, .even) { | |
| background-color: var(--plus-background-1); | |
| color: var(--plus-text-1); | |
| } | |
| /* 表头 */ | |
| .plus-styled table.grid:not(form table) tr:first-of-type > td:nth-of-type(2n+1) { | |
| background-color: var(--plus-background-2) !important; | |
| } | |
| /* 表行 */ | |
| .plus-styled table.grid td { | |
| background-color: var(--plus-background-1) !important; | |
| } | |
| /* 《文学少女》吐槽吧,不吐不快! */ | |
| .plus-styled table.grid:not(form table, #content .main > table:first-of-type) tr:first-of-type > td:first-of-type { | |
| color: var(--plus-text-title); | |
| } | |
| .plus-styled fieldset { | |
| border: 2px solid var(--plus-primary); | |
| } | |
| .plus-styled :is(table.grid, table.grid td, table.grid caption, .gridtop) { | |
| border: 1px solid var(--plus-primary); | |
| } | |
| `, | |
| }, | |
| bookindex: { | |
| checkers: [{ | |
| type: 'regpath', | |
| value: /^\/novel\/\d+\/\d+\/index\.html?$/ | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/reader.php' | |
| }], | |
| content: ` | |
| .plus-styled :is(.css, .vcss, .ccss) { | |
| background-color: var(--plus-background-1); | |
| color: var(--plus-text-1); | |
| } | |
| .plus-styled #headlink { | |
| border-bottom: 1px solid var(--plus-primary); | |
| border-top: 1px solid var(--plus-primary); | |
| } | |
| .plus-styled :is(.css, .vcss, .ccss) { | |
| border: 1px solid var(--plus-primary); | |
| border-collapse: collapse; | |
| } | |
| `, | |
| }, | |
| common: { | |
| content: ` | |
| /* 通用页面样式 */ | |
| body.plus-styled:not(#stonger-than-quasar) { | |
| background: var(--plus-page-bg); | |
| color: var(--plus-text); | |
| } | |
| `, | |
| }, | |
| dialog: { | |
| content: ` | |
| .plus-styled #dialog { | |
| color: var(--plus-text-1); | |
| background-color: var(--plus-background-1); | |
| border: 5px solid var(--plus-primary); | |
| } | |
| .plus-styled #dialog a[onclick="closeDialog()"] { | |
| border: 1px solid var(--plus-primary) !important; | |
| outline: thin solid var(--plus-primary) !important; | |
| } | |
| `, | |
| }, | |
| element: { | |
| content: ` | |
| .plus-styled :is(.even, .odd) { | |
| background-color: var(--plus-background-1); | |
| } | |
| .plus-styled table.grid td { | |
| background-color: var(--plus-background-1) !important; | |
| } | |
| .plus-styled :is(input:not([type]:not([type="text"], [type="number"], [type="file"], [type="password"])), textarea, .plus_list_item, button):not([class*="q-"]:not(body) *) { | |
| background-color: var(--plus-background-2); | |
| color: var(--plus-text-input); | |
| } | |
| .plus-styled :is(.button, input[type="button"]) { | |
| color: var(--plus-text-2); | |
| background-color: var(--plus-background-2); | |
| } | |
| .plus-styled select { | |
| color: var(--plus-text-2); | |
| background-color: var(--plus-background-2); | |
| } | |
| .plus-styled :is(.hottext, a.hottext) { | |
| color: var(--plus-text-hot); | |
| } | |
| .plus-styled :is(.button, select, textarea, input:not(.plus_list_item>input, .UBB_ColorList input), .plus_list_item):not(:disabled, [class*="q-"]:not(body) *) { | |
| border: 1px solid var(--plus-primary); | |
| } | |
| .plus-styled :is(input, textarea, button):disabled { | |
| border: 1px solid var(--plus-border-disabled); | |
| } | |
| .plus-styled a { | |
| color: var(--plus-text-link); | |
| } | |
| .plus-styled a:hover { | |
| color: var(--plus-text-link-hover); | |
| } | |
| .plus-styled a:is(.ultop li a, .poptext, a.poptext, .ultops li a) { | |
| color: var(--plus-text-hot); | |
| } | |
| .plus-styled :is(table.grid caption, .gridtop, table.grid th, .head) { | |
| border: 1px solid var(--plus-primary); | |
| background: var(--plus-background-title); | |
| color: var(--plus-text-title); | |
| } | |
| .plus-styled :is(table.grid, table.grid td) { | |
| border: 1px solid var(--plus-primary); | |
| } | |
| /* 未发现用处 | |
| .plus-styled input[type="checkbox"]::after { | |
| background-color: #333333; | |
| } | |
| */ | |
| /* 滚动条样式 */ | |
| :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement) { | |
| scrollbar-color: var(--plus-background-2) var(--plus-background-1); | |
| } | |
| :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement):hover { | |
| scrollbar-color: var(--plus-background-3) var(--plus-background-1); | |
| } | |
| :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar { | |
| background-color: var(--plus-background-1); | |
| } | |
| :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-corner { | |
| background-color: var(--plus-background-1); | |
| } | |
| :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-thumb, | |
| .plus-styled *:not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-button { | |
| background-color: var(--plus-background-2); | |
| } | |
| :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-thumb:hover, | |
| .plus-styled *:not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-button:hover { | |
| background-color: var(--plus-background-3); | |
| } | |
| `, | |
| }, | |
| frmreview: { | |
| content: ` | |
| .plus-styled form[name="frmreview"] caption { | |
| background: var(--plus-background-title); | |
| color: var(--plus-text-title); | |
| border: 1px solid var(--plus-primary); | |
| } | |
| .plus-styled .UBB_FontSizeList li { | |
| border: 1px solid var(--plus-primary); | |
| } | |
| .plus-styled .UBB_ColorList :is(table, table td) { | |
| border: 1px solid var(--plus-primary); | |
| } | |
| .plus-styled .UBB_ColorList { | |
| background-color: var(--plus-background-1); | |
| } | |
| `, | |
| }, | |
| headfoot: { | |
| content: ` | |
| .plus-styled :is(.main.m_top, .nav, .navinner, .navlist, .nav li, :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *)) { | |
| background: var(--plus-background-header); | |
| } | |
| .plus-styled :is(.nav a.current, .nav a:hover, .nav a:active) { | |
| background: var(--plus-background-header-active); | |
| } | |
| .plus-styled .m_foot { | |
| border-top: 1px dashed var(--plus-primary); | |
| border-bottom: 1px dashed var(--plus-primary); | |
| } | |
| `, | |
| }, | |
| indexpage: { | |
| checkers: [{ | |
| type: 'path', | |
| value: '/index.php' | |
| }, { | |
| type: 'path', | |
| value: '/' | |
| }], | |
| content: ` | |
| .plus-styled :is(:is(#left, #right, #centers) .blockcontent, .blockcontent, .odd, .even) { | |
| background-color: var(--plus-text-1); | |
| color: var(--plus-background-1); | |
| } | |
| .plus-styled :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *) a { | |
| color: var(--plus-text-link); | |
| } | |
| a[href^="http://tieba.baidu.com"] { | |
| color: var(--plus-text-link-highlight) !important; | |
| } | |
| `, | |
| }, | |
| mousetip: { | |
| content: ` | |
| .plus-styled #tips { | |
| background-color: var(--plus-background-title); | |
| color: var(--plus-text-title); /* #f0f7ff */ | |
| border: 1px solid var(--plus-primary); | |
| } | |
| `, | |
| }, | |
| novel: { | |
| checkers: { | |
| type: 'func', | |
| value: () => { | |
| return location.pathname.startsWith('/novel/') && location.pathname.split('/').pop() !== 'index.htm'; | |
| } | |
| }, | |
| content: ` | |
| .plus-styled a { | |
| color: var(--plus-text-link-highlight); | |
| } | |
| .plus-styled #content { | |
| color: var(--plus-text-1) !important; | |
| } | |
| ` | |
| }, | |
| reviewshow: { | |
| checkers: { | |
| type: 'regurl', | |
| value: /^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reviewshow\.php\/?/ | |
| }, | |
| content: ` | |
| .plus-styled table.grid td { | |
| background-color: var(--plus-background-1); | |
| } | |
| .plus-styled :is(#content table.grid hr, #content>table:nth-of-type(2) th, #pagelink) { | |
| border: 1px solid var(--plus-primary); | |
| } | |
| .plus-styled :is(.jieqiQuote, .jieqiCode, .jieqiNote) { | |
| background-color: var(--plus-background-2); | |
| color: var(--plus-text-2); | |
| border: 1px solid var(--plus-primary); | |
| } | |
| .plus-styled :is(.pagelink, .pagelink a:hover) { | |
| background-color: var(--plus-background-title); | |
| color: var(--plus-text-link-hover); | |
| } | |
| .plus-styled .pagelink strong { | |
| background-color: var(--plus-background-highlight); | |
| } | |
| .plus-styled .pagelink em { | |
| border-right: 1px solid var(--plus-primary); | |
| } | |
| .plus-styled .pagelink kbd { | |
| border-left: 1px solid var(--plus-primary); | |
| } | |
| .plus-styled .pagelink { | |
| border: 1px solid var(--plus-primary); | |
| } | |
| `, | |
| }, | |
| }; | |
| /** | |
| * 定义主题色的CSS(内置主题) | |
| * @type {Record<string, string>} | |
| */ | |
| const BuiltinThemes = { | |
| darkmode: ` | |
| /* 深色模式 */ | |
| body.plus-styled.plus-darkmode { | |
| /* 主要颜色 */ | |
| --plus-primary: var(--p-primary); | |
| /* 页面通用文字色和背景色 从底层到高层颜色逐渐加深或变浅 */ | |
| --plus-text-1: #C8C8C8; | |
| --plus-background-1: #222222; | |
| --plus-text-2: #C8C8C8; | |
| --plus-background-2: #282828; | |
| --plus-text-3: #ffffff; | |
| --plus-background-3: #383838; | |
| /* 特定用途/位置的颜色 */ | |
| --plus-text-title: #6f9ff1; | |
| --plus-text-input: #DDDDDD; | |
| --plus-background-title: #333333; | |
| --plus-background-highlight: #444444; | |
| --plus-text-hot: #f36d55; | |
| --plus-text-link: #AAAAAA; | |
| --plus-text-link-hover: #4a8dff; | |
| --plus-text-link-highlight: #4a8dff; | |
| --plus-border-disabled: #444444; | |
| --plus-background-header: #333333; | |
| --plus-background-header-active: #444444; | |
| } | |
| `, | |
| }; | |
| const Settings = CONST.Text.Styling.Settings; | |
| configs.registerConfig('styling', { | |
| GM_addValueChangeListener, | |
| items: [{ | |
| type: 'boolean', | |
| label: Settings.Enabled, | |
| caption: Settings.EnabledCaption, | |
| key: 'enabled', | |
| get() { return GM_getValue('enabled'); }, | |
| set(val) { GM_setValue('enabled', val); }, | |
| }], | |
| label: Settings.Title, | |
| listeners: { | |
| enabled(key, old_val, new_val, remote) { | |
| new_val ? install() : uninstall(); | |
| }, | |
| themes(key, old_val, new_val, remote) { | |
| // 比对新旧主题,将改动应用到页面 | |
| const old_ids = Object.keys(old_val); | |
| const new_ids = Object.keys(new_val); | |
| // 删除消失的主题 | |
| old_ids.filter(id => !new_ids.includes(id)).forEach( | |
| id => $(`plus-theme-${ id }`)?.remove()); | |
| // 添加新增的主题 | |
| new_ids.filter(id => !old_ids.includes(id)).forEach( | |
| id => addStyle(new_val[id], `plus-theme-${ id }`)); | |
| // 更新改变的主题 | |
| new_ids.filter(id => | |
| old_ids.includes(id) && old_val[id] !== new_val[id] | |
| ).forEach( | |
| id => addStyle(new_val[id], `plus-theme-${ id }`)); | |
| }, | |
| } | |
| }); | |
| GM_getValue('enabled') && install(); | |
| /** 安装本模块功能到页面 */ | |
| function install() { | |
| // 根据页面添加对应控制性css | |
| Object.entries(Styles).forEach(([id, style]) => { | |
| if (!style.checkers || FunctionLoader.testCheckers(style.checkers)) { | |
| addStyle(style.content, `plus-styling-${ id }`); | |
| } | |
| }); | |
| // 添加主题包到页面 | |
| const themes = Object.assign({}, BuiltinThemes, GM_getValue('themes')); | |
| Object.entries(themes).forEach(([id, css]) => addStyle(css, `plus-theme-${ id }`)); | |
| // body添加 plus-styled 类名 | |
| document.body.classList.add('plus-styled'); | |
| } | |
| /** 从页面卸载本模块功能 */ | |
| function uninstall() { | |
| // 移除所有控制性css | |
| Array.from($All('style[id^="plus-styling-"]')).forEach(s => s.remove()); | |
| // 移除所有主题包 | |
| Array.from($All('style[id^="plus-theme-"]')).forEach(s => s.remove()); | |
| // 移除 plus-styled 类名 | |
| document.body.classList.remove('plus-styled'); | |
| } | |
| /** | |
| * 安装一个新主题 | |
| * 这里只需要安装到存储,其他部分代码检测到存储变化会自动安装到页面的 | |
| * @param {string} id - 主题id,应全局唯一,如和已有主题id重复,则会更新该id对应主题的内容 | |
| * @param {string} css - 主题的css样式代码 | |
| */ | |
| function installTheme(id, css) { | |
| const themes = GM_getValue('themes'); | |
| themes[id] = css; | |
| GM_setValue('themes', themes); | |
| } | |
| /** | |
| * 卸载一个主题 | |
| * @param {string} id 主题的id | |
| */ | |
| function uninstallTheme(id) { | |
| const themes = GM_getValue('themes'); | |
| delete themes[id]; | |
| GM_setValue('themes', themes); | |
| } | |
| } | |
| }, | |
| unlocker: { | |
| desc: '各类网页端内容解锁', | |
| dependencies: ['api', 'utils', 'debugging'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.unlocker.func>>} unlocker */ | |
| async func() { | |
| /** @type {api} */ | |
| const api = require('api'); | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {debugging} */ | |
| const debugging = require('debugging'); | |
| const pool_funcs = { | |
| read: { | |
| desc: '在线阅读', | |
| checkers: { | |
| type: 'func', | |
| value() { | |
| const is_reader_page = ( | |
| location.pathname.startsWith('/novel/') | |
| || location.pathname.match(/\/modules\/article\/reader.php/) | |
| ) && unsafeWindow.chapter_id !== '0'; | |
| const need_unlock = $('#contentmain>:first-child')?.innerText.trim() === 'null'; | |
| return is_reader_page && need_unlock; | |
| } | |
| }, | |
| detectDom: '#footlink', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.read.func>>} read */ | |
| async func() { | |
| Quasar.Loading.show({ message: CONST.Text.Unlocker.FetchingContent }); | |
| const content = await api.getNovelContent({ | |
| aid: utils.window.article_id, | |
| cid: utils.window.chapter_id, | |
| lang: utils.getLanguage() | |
| }); | |
| const html = content | |
| .replaceAll(/[\r\n]+/g, '<br>') | |
| .replaceAll(' ', ' ') | |
| .replaceAll( | |
| /<!--image-->([^<]+?)<!--image-->/g, | |
| `<div class="divimage"><a href="$1" target="_blank"><img src="$1" border="0" class="imagecontent"></a></div>` | |
| ); | |
| [...$('#content').childNodes].forEach(elm => elm.remove()); | |
| $('#content').insertAdjacentHTML('afterbegin', html); | |
| Quasar.Loading.hide(); | |
| } | |
| }, | |
| download: { | |
| desc: '下载', | |
| checkers: [{ | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/articleinfo.php' | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/packshow.php' | |
| }], | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.download.func>>} download */ | |
| async func() { | |
| const pool = new FunctionLoader.FuncPool(); | |
| debugging.catchPoolErrors(pool); | |
| await pool.load([ | |
| { | |
| id: 'bookinfo', | |
| desc: '书籍介绍页', | |
| checkers: [{ | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, { | |
| type: 'startpath', | |
| value: '/modules/article/articleinfo.php' | |
| }], | |
| detectDom: '.main.m_foot', | |
| async func() { | |
| // 检查是否需要解锁 | |
| if ($('#content>div:first-child fieldset>legend>b')) { return; } | |
| // 需要解锁,创建下载页面入口 | |
| const aid = new URLSearchParams(location.search).get('id') ?? location.href.match(/book\/(\d+)\.htm/)[1]; | |
| const bookinfo = await api.getNovelShortInfo({ | |
| aid, lang: utils.getLanguage() | |
| }); | |
| const title = bookinfo.querySelector('[name="Title"]').firstChild.nodeValue; | |
| const div = $$CrE({ | |
| tagName: 'div', | |
| attrs: { | |
| style: 'margin:0px auto;overflow:hidden;' | |
| } | |
| }); | |
| div.innerHTML = ` | |
| <fieldset style="width:820px;height:35px;margin:0px auto;padding:0px;"> | |
| <legend><b>《${title}》小说TXT、UMD、JAR电子书下载</b></legend> | |
| <div style="width:210px; float:left; text-align:center;"><a href="https://${ location.host }/modules/article/packshow.php?id=${aid}&type=txt">TXT简繁分卷</a></div> | |
| <div style="width:210px; float:left; text-align:center;"><a href="https://${ location.host }/modules/article/packshow.php?id=${aid}&type=txtfull">TXT简繁全本</a></div> | |
| <div style="width:210px; float:left; text-align:center;"><a href="https://${ location.host }/modules/article/packshow.php?id=${aid}&type=umd">UMD全本下载</a></div> | |
| <div style="width:190px; float:left; text-align:center;"><a href="https://${ location.host }/modules/article/packshow.php?id=${aid}&type=jar">JAR全本下载</a></div> | |
| </fieldset> | |
| `; | |
| $('#content>div:first-child').insertAdjacentElement('beforeend', div); | |
| } | |
| }, | |
| { | |
| id: 'download', | |
| desc: '下载页', | |
| checkers: { | |
| type: 'startpath', | |
| value: '/modules/article/packshow.php' | |
| }, | |
| async func() { | |
| /* | |
| 页面加载思路: | |
| 1. 在锁定的页面引入iframe,导航至文学少女的对应packshow页面 | |
| 2. iframe内运行的脚本实例负责将此页面修改为对应书籍的页面 | |
| 因此需要加载两个不同oFunc: | |
| 1. 检测到锁定页面内容,引入iframe | |
| 2. 检测到外部为锁定页面的文学少女iframe,修改页面内容 | |
| */ | |
| const pool = new FunctionLoader.FuncPool(); | |
| debugging.catchPoolErrors(pool); | |
| await pool.load([ | |
| { | |
| id: 'outer', | |
| desc: '外部锁定页面', | |
| checkers: { | |
| type: 'switch', | |
| value: isLockedPage(utils.window) | |
| }, | |
| detectDom: '.blocknote, .main.m_foot', | |
| func() { | |
| Quasar.Loading.show({ message: CONST.Text.Unlocker.ConstructingPage }); | |
| const search = new URLSearchParams(location.search); | |
| const url = new URL(location.href); | |
| search.set('id', CONST.Internal.UnlockTemplateAID.toString()); | |
| url.search = search.toString(); | |
| const iframe = $$CrE({ | |
| tagName: 'iframe', | |
| props: { | |
| src: url.href | |
| }, | |
| styles: { | |
| position: 'fixed', | |
| top: '0', | |
| left: '0', | |
| width: '100vw', | |
| height: '100vh', | |
| border: '0', | |
| padding: '0', | |
| margin: '0', | |
| background: 'white', | |
| zIndex: '-1', | |
| opacity: '0.001', | |
| }, | |
| listeners: [['load', e => { | |
| Quasar.Loading.hide(); | |
| iframe.style.zIndex = '1'; | |
| iframe.style.opacity = '1'; | |
| document.body.style.overflow = 'hidden'; | |
| }]] | |
| }); | |
| document.body.append(iframe); | |
| } | |
| }, | |
| { | |
| id: 'inner', | |
| desc: '内部《文学少女》页面', | |
| checkers: { | |
| type: 'func', | |
| value() { | |
| const in_iframe = utils.window.top !== utils.window; | |
| const id_corrent = new URLSearchParams(location.search).get('id') === CONST.Internal.UnlockTemplateAID.toString(); | |
| const outer_locked = isLockedPage(utils.window.top); | |
| return in_iframe && id_corrent && outer_locked; | |
| }, | |
| }, | |
| async func() { | |
| Quasar.Loading.show({ message: CONST.Text.Unlocker.FetchingDownloadInfo }); | |
| // 获取书籍信息 | |
| const aid = new URLSearchParams(utils.window.top.location.search).get('id'); | |
| const lang = utils.getLanguage(); | |
| const [templateinfo, bookinfo, bookindex] = await Promise.all([ | |
| api.getNovelFullMeta({ aid: CONST.Internal.UnlockTemplateAID, lang }), | |
| api.getNovelFullMeta({ aid, lang }), | |
| api.getNovelIndex({ aid, lang }) | |
| ]); | |
| const template_title = templateinfo.querySelector('[name="Title"]').firstChild.nodeValue; | |
| const book_title = $(bookinfo, '[name="Title"]').firstChild.nodeValue; | |
| const book_update = $(bookinfo, '[name="LastUpdate"]').getAttribute('value'); | |
| const book_length = $(bookinfo, '[name="BookLength"]').getAttribute('value'); | |
| // 处理页面内导航 | |
| $AEL(document, 'click', function(event) { | |
| const anchor = event.target.closest('a'); | |
| if (anchor && anchor.href && !anchor.target) { | |
| if (!event.ctrlKey && !event.metaKey && !event.shiftKey) { | |
| event.preventDefault(); | |
| window.top.location.href = anchor.href; | |
| } | |
| } | |
| }, true); | |
| // 页面标题 | |
| utils.window.top.document.title = document.title.replaceAll(template_title, book_title); | |
| // 所有指向《文学少女》的链接改为指向目标书籍 | |
| detectDom({ | |
| selector: 'a', | |
| callback(a) { | |
| const template_pathname = `/book/${CONST.Internal.UnlockTemplateAID}.htm`; | |
| if (a.pathname === template_pathname) { | |
| a.pathname = `/book/${aid}.htm`; | |
| } | |
| } | |
| }); | |
| // 下载表格表头标题书名改为目标书籍书名 | |
| (await detectDom('#content>table>caption>a')).innerText = book_title; | |
| // 重建下载列表 | |
| [...$All('#content>table tr:not(:first-of-type)')].forEach(tr => tr.remove()); | |
| const tbody = $('#content>table>tbody'); | |
| const type = new URLSearchParams(location.search).get('type'); | |
| const list_builders = { | |
| async txt() { | |
| for (const volume of $All(bookindex, 'volume')) { | |
| const volume_title = volume.firstChild.nodeValue; | |
| const vid = volume.getAttribute('vid'); | |
| const tr = $CrE('tr'); | |
| tr.innerHTML = ` | |
| <td class="odd">${ volume_title }</td> | |
| <td class="even" align="center"> | |
| <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&charset=gbk" target="_blank">简体(G)</a> | |
| <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&charset=utf-8" target="_blank">简体(U)</a> | |
| <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&charset=big5" target="_blank">繁体(U)</a> | |
| </td> | |
| <td class="even" align="center"> | |
| <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&aname=${ $URL.encode(book_title) }&vname=${ $URL.encode(volume_title) }&charset=gbk" target="_blank">简体(G)</a> | |
| <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&aname=${ $URL.encode(book_title) }&vname=${ $URL.encode(volume_title) }&charset=utf-8" target="_blank">简体(U)</a> | |
| <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&aname=${ $URL.encode(book_title) }&vname=${ $URL.encode(volume_title) }&charset=big5" target="_blank">繁体(U)</a> | |
| </td> | |
| `; | |
| tbody.append(tr); | |
| } | |
| }, | |
| async txtfull() { | |
| const tr = $CrE('tr'); | |
| tr.innerHTML = ` | |
| <td class="odd" align="center">${ book_update }</td> | |
| <td class="even" align="center">${ Math.round(book_length * 2 / 1024) }K(G版) / ${ Math.round(book_length * 3 / 1024) }K(U版)</td> | |
| <td class="even" align="center"> | |
| 简体(G)(<a href="https://dl.wenku8.com/down.php?type=txt&node=1&id=${ aid }" target="_blank">载点一</a> | |
| <a href="https://dl.wenku8.com/down.php?type=txt&node=2&id=${ aid }" target="_blank">载点二</a>) | |
| 简体(U)(<a href="https://dl.wenku8.com/down.php?type=utf8&node=1&id=${ aid }" target="_blank">载点一</a> | |
| <a href="https://dl.wenku8.com/down.php?type=utf8&node=2&id=${ aid }" target="_blank">载点二</a>) | |
| 繁体(U)(<a href="https://dl.wenku8.com/down.php?type=big5&node=1&id=${ aid }" target="_blank">载点一</a> | |
| <a href="https://dl.wenku8.com/down.php?type=big5&node=2&id=${ aid }" target="_blank">载点二</a>) | |
| </td> | |
| `; | |
| tbody.append(tr); | |
| }, | |
| async umd() { | |
| const tr = $CrE('tr'); | |
| tr.innerHTML = ` | |
| <td class="odd" align="center">全本</td> | |
| <td class="even" align="center">未知</td> | |
| <td class="odd" align="center">${ book_update }</td> | |
| <td class="odd">${ $(bookindex, 'volume:first-of-type').firstChild.nodeValue } - ${ $(bookindex, 'volume:last-of-type').firstChild.nodeValue }</td> | |
| <td class="even" align="center"><a href="https://dl.wenku8.com/down.php?type=umd&id=${ aid }&vsize=0&vid=1" target="_blank">下载UMD</a> | |
| </td> | |
| `; | |
| tbody.append(tr); | |
| }, | |
| async jar() { | |
| const tr = $CrE('tr'); | |
| tr.innerHTML = ` | |
| <td class="odd" align="center">全本</td> | |
| <td class="even" align="center">未知</td> | |
| <td class="odd" align="center">${ book_update }</td> | |
| <td class="odd">${ $(bookindex, 'volume:first-of-type').firstChild.nodeValue } - ${ $(bookindex, 'volume:last-of-type').firstChild.nodeValue }</td> | |
| <td class="even" align="center"><a href="https://dl.wenku8.com/down.php?type=jar&id=${ aid }&vsize=0&vid=1" target="_blank">下载JAR</a> <a href="https://dl.wenku8.com/down.php?type=jad&id=${ aid }&vsize=0&vid=1" target="_blank">下载JAD</a></td> | |
| `; | |
| tbody.append(tr); | |
| }, | |
| }; | |
| await list_builders[type](); | |
| Quasar.Loading.hide(); | |
| } | |
| } | |
| ]); | |
| /** | |
| * 判断给定页面是否为锁定的下载页面 | |
| * @param {Window} win | |
| * @returns {boolean} | |
| */ | |
| function isLockedPage(win) { | |
| const path_correct = win.location.pathname.startsWith('/modules/article/packshow.php'); | |
| const messages = [ | |
| '错误原因:对不起,该文章不存在!', | |
| '錯誤原因︰對不起,該文章不存在!' | |
| ] | |
| const content_correct = messages.some(message => win.document.body.innerText.includes(message)); | |
| return path_correct && content_correct; | |
| } | |
| } | |
| } | |
| ]); | |
| } | |
| } | |
| }; | |
| const { pool, promise } = utils.loadFuncInNewPool(pool_funcs); | |
| await promise; | |
| return { | |
| /** @type {read} */ | |
| read: pool.require('read'), | |
| /** @type {download} */ | |
| download: pool.require('download'), | |
| }; | |
| } | |
| }, | |
| darkmode: { | |
| desc: '深色模式', | |
| css: [ | |
| // Common | |
| { | |
| id: 'common', | |
| checker: { | |
| type: 'switch', | |
| value: true, | |
| }, | |
| css: 'body.plus-darkmode:not(#stonger-than-quasar) {background-color: #222222;color: #C8C8C8;}' | |
| }, | |
| // Mouse tip | |
| { | |
| id: 'mousetip', | |
| checker: { | |
| type: 'regurl', | |
| value: /^https?:\/\/www\.wenku8\.(net|cc)\// | |
| }, | |
| css: '.plus-darkmode #tips {background-color: #333333;color: #f0f7ff;border: 1px solid var(--p-primary);}' | |
| }, | |
| // .block | |
| { | |
| id: 'block', | |
| checker: { | |
| type: 'regurl', | |
| value: /^https?:\/\/www\.wenku8\.(net|cc)\// | |
| }, | |
| css: '.plus-darkmode :is(#left,#right,*) .blockcontent{background-color:#222222}.plus-darkmode :is(#left,#right,*) .blocknote{background-color:#282828}.plus-darkmode :is(#left,#right,#centers,*) :is(.blocktitle,.blocktitle *,.ultop li){color:#6f9ff1}.plus-darkmode :is(#left,#right,#centers,*) .blocktitle>:is(.txt,.txtr){background-color:#383838;line-height:27px;padding-top:0}.plus-darkmode .block{border:1px solid var(--p-primary)}.plus-darkmode .blocktitle{border-color:#333333}.plus-darkmode :is(.blockcontent,.blocknote){border-color:var(--p-primary)}.plus-darkmode .block :is(.ultop li,.ultops li){border-bottom:1px dashed var(--p-primary)}' | |
| }, | |
| // header and footer | |
| { | |
| id: 'headfoot', | |
| checker: { | |
| type: 'regurl', | |
| value: /^https?:\/\/www\.wenku8\.(net|cc)\// | |
| }, | |
| css: '.plus-darkmode :is(.main.m_top, .nav, .navinner, .navlist, .nav li, :is(#left, #right, #centers, *) .blocktitle) {background: #333333;}.plus-darkmode :is(.main.m_top, .nav, .navinner, .navlist, .nav li, :is(#left, #right, #centers, *) .blocktitle > :is(.txt, .txtr)) {background: #383838;}.plus-darkmode :is(.nav a.current, .nav a:hover, .nav a:active) {background: #444444;}.plus-darkmode .m_foot {border-top: 1px dashed var(--p-primary);border-bottom: 1px dashed var(--p-primary);}' | |
| }, | |
| // elements (input textarea .button scrollbar, etc) | |
| { | |
| id: 'element', | |
| checker: { | |
| type: 'regurl', | |
| value: /^https?:\/\/www\.wenku8\.(net|cc)\// | |
| }, | |
| css: '.plus-darkmode :is(.even, .odd) {background-color: #222222;}.plus-darkmode table.grid td {background-color: #222222 !important;}.plus-darkmode :is(input:not([type]:not([type="text"], [type="number"], [type="file"], [type="password"])), textarea, .plus_list_item, button):not([class*="q-"]:not(body) *) {background-color: #333333;color: #DDDDDD;}.plus-darkmode :is(.button, input[type="button"]) {color: #C8C8C8;background-color: #333333;}.plus-darkmode select {color: #AAAAAA;background-color: #333333;}.plus-darkmode :is(.hottext, a.hottext) {color: #f36d55;}.plus-darkmode :is(.button, select, textarea, input:not(.plus_list_item>input, .UBB_ColorList input), .plus_list_item):not(:disabled, [class*="q-"]:not(body) *) {border: 1px solid var(--p-primary);}.plus-darkmode :is(input, textarea, button):disabled {border: 2px solid #444444;}.plus-darkmode a {color: #AAAAAA;}.plus-darkmode a:hover {color: #4a8dff;}.plus-darkmode a:is(.ultop li a, .poptext, a.poptext, .ultops li a) {color: #f36d55;}.plus-darkmode :is(table.grid caption, .gridtop, table.grid th, .head) {border: 1px solid var(--p-primary);background: #333333;color: #6f9ff1;}.plus-darkmode :is(table.grid, table.grid td) {border: 1px solid var(--p-primary);}.plus-darkmode input[type="checkbox"]::after {background-color: #333333;}.plus-darkmode :is(.pagelink, .pagelink a:hover) {background-color: #333333;color: #6f9ff1;}.plus-darkmode .pagelink strong {background-color: #444444;}.plus-darkmode .pagelink em {border-right: 1px solid var(--p-primary);}.plus-darkmode .pagelink kbd {border-left: 1px solid var(--p-primary);}.plus-darkmode .pagelink {border: 1px solid var(--p-primary);}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement) {scrollbar-color: #444444 #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement):hover {scrollbar-color: #484848 #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar {background-color: #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-corner {background-color: #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-thumb, .plus-darkmode *:not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-button {background-color: #444444;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-thumb:hover, .plus-darkmode *:not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-button:hover {background-color: #484848;}' | |
| }, | |
| // dialog | |
| { | |
| id: 'dialog', | |
| checker: { | |
| type: 'regurl', | |
| value: /^https?:\/\/www\.wenku8\.(net|cc)\// | |
| }, | |
| css: '.plus-darkmode #dialog {color: #C8C8C8;background-color: #222222;border: 5px solid var(--p-primary);}.plus-darkmode #dialog a[onclick="closeDialog()"] {border: 1px solid var(--p-primary) !important;outline: thin solid var(--p-primary) !important;}' | |
| }, | |
| // replyarea | |
| { | |
| id: 'replyarea', | |
| checker: [ | |
| // Page: reviews list | |
| '/modules/article/reviews.php', | |
| // Page: review | |
| '/modules/article/reviewshow.php', | |
| // Page: review edit | |
| '/modules/article/reviewedit.php', | |
| // Page: book | |
| '/book/', | |
| '/modules/article/articleinfo.php', | |
| ].map(p => ({ | |
| type: 'startpath', | |
| value: p | |
| })), | |
| css: '.plus-darkmode form[name="frmreview"] caption {background: #333333;color: #6f9ff1;border: 1px solid var(--p-primary);}' | |
| }, | |
| // index page | |
| { | |
| id: 'index', | |
| checker: [{ | |
| type: 'path', | |
| value: '/index.php' | |
| }, { | |
| type: 'path', | |
| value: '/' | |
| }], | |
| css: '.plus-darkmode :is(:is(#left, #right, #centers) .blockcontent, .blockcontent, .odd, .even) {background-color: #222222;color: #C8C8C8;}.plus-darkmode :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *) a {color: #AAAAAA;}a[href^="http://tieba.baidu.com"] {color: #4a8dff !important;}', | |
| }, | |
| // login page | |
| { | |
| id: 'login', | |
| checker: { | |
| type: 'path', | |
| value: '/login.php' | |
| }, | |
| css: '' | |
| }, | |
| // Book | |
| { | |
| id: 'book', | |
| checker: [{ | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, { | |
| type: 'regpath', | |
| value: /\/modules\/article\/articleinfo\.php/ | |
| }], | |
| css: '.plus-darkmode :is(:is(#left, #right, #centers) .blockcontent, .blockcontent, .odd, .even) {background-color: #222222;color: #C8C8C8;}.plus-darkmode table.grid td {background-color: #222222 !important;}.plus-darkmode table.grid:not(form table) tr:first-of-type>td:nth-of-type(2n+1) {background-color: #333333 !important;}.plus-darkmode table.grid:not(form table, #content .main>table:first-of-type) tr:first-of-type>td:first-of-type {color: #6f9ff1;}.plus-darkmode fieldset {border: 2px solid var(--p-primary);}.plus-darkmode :is(table.grid, table.grid td, table.grid caption, .gridtop) {border: 1px solid var(--p-primary);}' | |
| }, | |
| // Book index | |
| { | |
| id: 'bookindex', | |
| checker: [{ | |
| type: 'regpath', | |
| value: /^\/novel\/\d+\/\d+\/index\.html?$/ | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/reader.php' | |
| }], | |
| css: '.plus-darkmode :is(.css, .vcss, .ccss) {background-color: #222222;color: #C8C8C8;}.plus-darkmode #headlink {border-bottom: 1px solid var(--p-primary);border-top: 1px solid var(--p-primary);}.plus-darkmode :is(.css, .vcss, .ccss) {border: 1px solid var(--p-primary);border-collapse: collapse;}' | |
| }, | |
| // Novel | |
| { | |
| id: 'novel', | |
| checker: { | |
| type: 'func', | |
| value: () => { | |
| return location.pathname.startsWith('/novel/') && location.pathname.split('/').pop() !== 'index.htm'; | |
| } | |
| }, | |
| css: '.plus-darkmode a {color: #4a8dff;} .plus-darkmode #content {color: rgb(200, 200, 200);}' | |
| }, | |
| // Reviewshow | |
| { | |
| id: 'reviewshow', | |
| checker: { | |
| type: 'regurl', | |
| value: /^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reviewshow\.php\/?/ | |
| }, | |
| css: '.plus-darkmode table.grid td {background-color: #222222;}.plus-darkmode :is(#content table.grid hr, #content>table:nth-of-type(2) th, #pagelink) {border: 1px solid var(--p-primary);}.plus-darkmode :is(.jieqiQuote, .jieqiCode, .jieqiNote) {background-color: #282828;color: #6f9ff1;border: 1px solid var(--p-primary);}' | |
| }, | |
| // frmreview | |
| { | |
| id: 'frmreview', | |
| checker: [ | |
| // Page: reviews list | |
| '/modules/article/reviews.php', | |
| // Page: review | |
| '/modules/article/reviewshow.php', | |
| // Page: review edit | |
| '/modules/article/reviewedit.php', | |
| // Page: book | |
| '/book/', | |
| '/modules/article/articleinfo.php', | |
| ].map(p => ({ | |
| type: 'startpath', | |
| value: p | |
| })), | |
| css: '.plus-darkmode .UBB_FontSizeList li {border: 1px solid var(--p-primary);}.plus-darkmode .UBB_ColorList :is(table, table td) {border: 1px solid var(--p-primary);}.plus-darkmode .UBB_ColorList {background-color: #222222;}' | |
| }, | |
| /* Template | |
| { | |
| id: '', | |
| checker: { | |
| type: 'regurl', | |
| value: /^https?:\/\/www\.wenku8\.(net|cc)\// | |
| }, | |
| css: '' | |
| }, | |
| */ | |
| ], | |
| dependencies: ['dependencies', 'utils', 'debugging', 'configs'], | |
| params: ['oFunc', 'GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.darkmode.func>>} darkmode */ | |
| async func(oFunc, GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {configs} */ | |
| const configs = require('configs'); | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {debugging} */ | |
| const debugging = require('debugging'); | |
| // 带默认值的GM_getValue | |
| GM_getValue = utils.defaultedGet({ | |
| enabled: false, | |
| follow_system: false, | |
| sidebutton: true, | |
| }, GM_getValue); | |
| // 设置项、配置存储管理 与 基于设置项的功能切换 | |
| configs.registerConfig('darkmode', { | |
| GM_addValueChangeListener, | |
| label: CONST.Text.Darkmode.Settings.Label, | |
| items: [{ | |
| type: 'boolean', | |
| label: CONST.Text.Darkmode.Settings.Enbaled, | |
| caption: CONST.Text.Darkmode.Settings.EnabledCaption, | |
| key: 'enabled', | |
| get() { return isEnabled(); }, | |
| set(val) { setEnabled(val); }, | |
| }, { | |
| type: 'boolean', | |
| label: CONST.Text.Darkmode.Settings.FollowSystem, | |
| caption: CONST.Text.Darkmode.Settings.FollowSystemCaption, | |
| key: 'follow_system', | |
| get() { return isFollow(); }, | |
| set(val) { setFollow(val); } | |
| }, { | |
| type: 'boolean', | |
| label: CONST.Text.Darkmode.Settings.SideButton, | |
| caption: CONST.Text.Darkmode.Settings.SideButtonCaption, | |
| key: 'sidebutton', | |
| get() { return GM_getValue('sidebutton'); }, | |
| set(val) { return GM_setValue('sidebutton', val); }, | |
| }], | |
| listeners: { | |
| enabled: applyDarkmode, | |
| follow_system: applyDarkmode, | |
| sidebutton(key, old_val, val, remote) { | |
| updateSideButton(val); | |
| } | |
| }, | |
| }); | |
| // 应用合适的样式表到页面 | |
| oFunc.css.forEach(css => { | |
| FunctionLoader.testCheckers(css.checker) && addStyle(css.css, `darkmode-${css.id}`); | |
| }); | |
| // 深色模式切换回调列表 | |
| /** @type {((enabled: boolean) => any)[]} */ | |
| const listeners = []; | |
| // 根据配置切换深色模式 | |
| applyDarkmode(); | |
| // 每当系统深色模式切换时,重新根据配置切换深色模式 | |
| const darkmode_mediaquery = window.matchMedia('(prefers-color-scheme: dark)'); | |
| $AEL(darkmode_mediaquery, 'change', e => applyDarkmode()); | |
| // 侧边栏添加深色模式开关 | |
| require('sidepanel', true).then(() => updateSideButton()); | |
| /** | |
| * 检查深色模式是否开启 | |
| * @returns {boolean} | |
| */ | |
| function isEnabled() { | |
| return GM_getValue('enabled'); | |
| } | |
| /** | |
| * 设置深色模式开启状态,并应用到页面 | |
| * @param {boolean} enabled - 深色模式是否开启 | |
| */ | |
| function setEnabled(enabled) { | |
| GM_setValue('enabled', enabled); | |
| } | |
| /** | |
| * 检查深色模式是否跟随系统 | |
| * @returns {boolean} | |
| */ | |
| function isFollow() { | |
| return GM_getValue('follow_system'); | |
| } | |
| /** | |
| * 设置深色模式跟随系统,并应用到页面 | |
| * @param {boolean} follow - 深色模式是否跟随系统 | |
| */ | |
| function setFollow(follow) { | |
| GM_setValue('follow_system', follow); | |
| } | |
| /** | |
| * 根据设置综合计算是否应用深色模式,并应用更改到页面;当实际发生更改时,回调listeners | |
| */ | |
| function applyDarkmode() { | |
| const enabled = isActualDark(); | |
| const cur_enabled = document.body.classList.contains('plus-darkmode'); | |
| cur_enabled !== enabled && setActualDark(enabled); | |
| } | |
| /** | |
| * 设置当前页面实际的深色模式开启状态 | |
| * 通常情况下,不应在模块外调用,仅在模块内根据用户设置自动调用此方法 | |
| * 但是,确实必要时,可以在模块外显式调用此方法,临时性地强制设置当前页面的深色模式开启状态 | |
| * @param {boolean} actual_enabled - 是否在页面实际应用深色模式 | |
| */ | |
| function setActualDark(actual_enabled) { | |
| document.body.classList[actual_enabled ? 'add' : 'remove']('plus-darkmode'); | |
| require('dependencies', true).then(() => Quasar.Dark.set(actual_enabled)); | |
| listeners.forEach(listener => debugging.callWithErrorHandling(listener, null, [actual_enabled])); | |
| } | |
| /** | |
| * 获取各种设置综合效果下的**实际深色模式启用状态** | |
| * 注意:如果在模块外显式调用过{@link setActualDark},那么本方法的返回结果可能和实际页面状态不符 | |
| * @returns {boolean} | |
| */ | |
| function isActualDark() { | |
| return isFollow() ? getSystemDarkmode() : isEnabled(); | |
| } | |
| /** | |
| * 检测系统深色模式是否开启 | |
| * @returns {boolean} | |
| */ | |
| function getSystemDarkmode() { | |
| return window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| } | |
| /** | |
| * 切换是否展示深色模式开关按钮 | |
| * @param {boolean} [show_button] - 是否展示开关按钮,不提供时使用存储的配置 | |
| */ | |
| async function updateSideButton(show_button) { | |
| /** @type {sidepanel} */ | |
| const sidepanel = await require('sidepanel', true); | |
| show_button = show_button ?? GM_getValue('sidebutton'); | |
| if (show_button) { | |
| sidepanel.hasButton('darkmode.toggle') || sidepanel.registerButton({ | |
| id: 'darkmode.toggle', | |
| icon: isEnabled() ? 'light_mode' : 'dark_mode', | |
| label: isEnabled() ? CONST.Text.Darkmode.Switch2Light : CONST.Text.Darkmode.Switch2Dark, | |
| index: 1, | |
| callback() { | |
| const enabled = !isEnabled(); | |
| sidepanel.updateButton('darkmode.toggle', { | |
| icon: enabled ? 'light_mode' : 'dark_mode', | |
| label: enabled ? CONST.Text.Darkmode.Switch2Light : CONST.Text.Darkmode.Switch2Dark | |
| }); | |
| setEnabled(enabled); | |
| if (isFollow()) { | |
| Quasar.Notify.create({ | |
| type: 'warning', | |
| message: CONST.Text.Darkmode.FollowEnabledTip, | |
| caption: CONST.Text.Darkmode.FollowEnabledTipCaption, | |
| group: 'darkmode.darkmode-tip', | |
| }); | |
| } | |
| } | |
| }); | |
| } else { | |
| sidepanel.hasButton('darkmode.toggle') && sidepanel.removeButton('darkmode.toggle'); | |
| } | |
| } | |
| /** | |
| * 注册当页面实际深色/浅色模式进行切换时的回调 | |
| * @param {(enabled: boolean) => any} callback - 页面实际深色/浅色模式切换的回调,参数为深色模式是否开启 | |
| */ | |
| function onToggle(callback) { | |
| listeners.push(callback); | |
| } | |
| /** | |
| * 根据url,筛选出属于此页面的css样式列表 | |
| * @param {string} url - 页面url | |
| * @returns {string[]} - 全部样式css的数组 | |
| */ | |
| function getPageCSS(url) { | |
| return oFunc.css.filter(css => FunctionLoader.testCheckers(css.checker)).map(css => css.css); | |
| } | |
| /** | |
| * 获取指定的css样式字符串 | |
| * @param {string} id - css样式的id | |
| */ | |
| function getCSS(id) { | |
| return oFunc.css.find(css => css.id === id).css; | |
| } | |
| /** | |
| * 将指定的css作为<style>元素添加到指定的父元素中 | |
| * @param {string} id - css样式的id | |
| * @param {HTMLElement} parent - 父元素 | |
| */ | |
| function applyCSS(id, parent) { | |
| addStyle(parent, getCSS(id)); | |
| } | |
| return { | |
| get enabled() { return isEnabled(); }, | |
| set enabled(val) { setEnabled(val); }, | |
| get follow_system() { return isFollow(); }, | |
| set follow_system(val) { setFollow(val); }, | |
| get actual_enabled() { return isActualDark(); }, | |
| onToggle, getPageCSS, getCSS, applyCSS, setActualDark, | |
| }; | |
| } | |
| }, | |
| review: { | |
| desc: '书评页面增强', | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/reviewshow.php' | |
| }, | |
| dependencies: ['dependencies', 'debugging', 'utils', 'configs', 'history', 'bbcode'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.review.func>>} review */ | |
| async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {configs} */ | |
| const configs = require('configs'); | |
| /** @type {history} */ | |
| const history = require('history'); | |
| /** @type {bbcode} */ | |
| const bbcode = require('bbcode'); | |
| // 如果是发评论返回的提示页面,不继续运行 | |
| if ($All('.block').length === 1) { return; } | |
| // 注册设置组 | |
| configs.registerConfig('review', { | |
| GM_addValueChangeListener, | |
| label: CONST.Text.Review.Settings.Label | |
| }); | |
| /* | |
| 通信信使,通过CustomEvent传递消息,目前有以下事件: | |
| - update | |
| 代表当前页面内容被更新,有楼层被更新,或有新楼层加入页面 | |
| - floors | |
| 被更新或者新增的楼层实例 | |
| */ | |
| const messager = new EventTarget(); | |
| /** | |
| * 书评页面每条评论称为一个楼层,即Floor | |
| * Floor类型表示一个楼层,一条评论 | |
| * @typedef {Object} Floor | |
| * @property {FloorElement} element | |
| * @property {FloorData} data | |
| */ | |
| /** | |
| * {@link Floor} 类型中的页面元素 | |
| * @typedef {Object} FloorElement | |
| * @property {HTMLTableElement} root - table根元素 | |
| * @property {HTMLTableCellElement} userarea - 左侧用户区 | |
| * @property {HTMLImageElement} avatar - 用户头像图片元素 | |
| * @property {HTMLAnchorElement} userlink - 用户名链接 | |
| * @property {FloorUserLine[]} userlines - 用户区域中的用户信息行结合 | |
| * @property {FloorButton[]} userbuttons - 用户相关操作按钮集合 | |
| * @property {HTMLTableCellElement} contentarea - 右侧内容区 | |
| * @property {HTMLElement} title - 标题strong元素 | |
| * @property {FloorButton[]} floorbuttons - 楼层相关操作按钮集合 | |
| * @property {HTMLDivElement} metaarea - 楼层相关操作按钮,以及楼层时间所在容器 | |
| * @property {HTMLDivElement} content - 内容正文区 | |
| */ | |
| /** | |
| * {@link FloorElement} 类型中的操作按钮 | |
| * @typedef {Object} FloorButton | |
| * @property {string} id - 按钮ID,全局唯一 | |
| * @property {boolean} wenku - 是否为文库自带按钮 | |
| * @property {number} index - 按钮排序位置,文库自带按钮均为负数,新添加按钮均为正数,升序排列 | |
| * @property {HTMLElement} element - 按钮DOM元素 | |
| */ | |
| /** | |
| * {@link FloorElement} 类型中的用户信息行 | |
| * @typedef {Object} FloorUserLine | |
| * @property {string} id - 行ID,全局唯一 | |
| * @property {boolean} wenku - 是否为文库自带行 | |
| * @property {HTMLElement | Text} element - 行DOM节点 | |
| */ | |
| /** | |
| * {@link Floor} 类型中的楼层数据 | |
| * @typedef {Object} FloorData | |
| * @property {FloorUser} user - 层主用户数据 | |
| * @property {string} title - 楼层标题 | |
| * @property {string} content - 楼层内容 | |
| * @property {number} time - 楼层时间戳 | |
| * @property {string} url - 楼层链接 | |
| * @property {number} rid - 书评id | |
| * @property {number} yid - 楼层id | |
| * @property {number} number - 楼层编号 | |
| * @property {boolean} highlight - 是否对楼层应用了高亮效果 | |
| */ | |
| /** | |
| * {@link FloorData} 类型中的用户数据 | |
| * @typedef {Object} FloorUser | |
| * @property {string} avatar - 用户头像src | |
| * @property {string} name - 用户名 | |
| * @property {number} id - 用户数字id | |
| * @property {FloorUserType} type - 用户类型 | |
| * @property {FloorUserLevel} level - 用户等级 | |
| * @property {number} jointime - 加入日期时间戳 | |
| * @property {number} experience - 经验 | |
| * @property {number} credit - 积分 | |
| */ | |
| /** | |
| * 用户类型 | |
| * @typedef {'admin' | 'user' | 'banned' | 'limited'} FloorUserType | |
| */ | |
| /** | |
| * 用户等级 | |
| * @typedef {'newbie' | 'normal' | 'intermediate' | 'advanced' | 'golden' | 'elder'} FloorUserLevel | |
| */ | |
| const pool_funcs = { | |
| FloorManager: { | |
| desc: '楼层内容解析器', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.FloorManager.func>>} FloorManager */ | |
| async func() { | |
| const pool_funcs = { | |
| parser: { | |
| desc: '楼层内容解析器', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.parser.func>>} parser */ | |
| func() { | |
| /** | |
| * 从给定文档中解析所有Floor | |
| * @param {Document} [doc=document] - 需解析的Document文档,默认为window.document,也可以是任何其他文档,如从xhr请求获取的文档 | |
| * @returns {Floor[]} | |
| */ | |
| function parseAll(doc = document) { | |
| return [...$All(doc, '#content > table.grid')].filter(table => $(table, 'img.avatar')).map(table => parse(table, doc)); | |
| } | |
| /** | |
| * 将楼层DOM结构解析为标准楼层对象 | |
| * 仅可解析未经修改的Wenku自带DOM | |
| * @param {HTMLTableElement} table - 楼层的table根元素 | |
| * @param {Document} doc - 所在Document文档 | |
| * @returns {Floor} | |
| */ | |
| function parse(table, doc) { | |
| const element = parseElement(table, doc); | |
| const data = parseData(element, doc); | |
| return { element, data }; | |
| } | |
| /** | |
| * 从楼层DOM结构解析标准楼层元素对象 | |
| * 仅可解析未经修改的Wenku自带DOM | |
| * @param {HTMLTableElement} table - 楼层的table根元素 | |
| * @param {Document} doc - 所在Document文档 | |
| * @returns {FloorElement} | |
| */ | |
| function parseElement(table, doc) { | |
| const userarea = $(table, 'td:first-of-type'); | |
| const contentarea = $(table, 'td:last-of-type'); | |
| const avatar = $(userarea, 'img.avatar'); | |
| const userlink = $(userarea, 'strong>a'); | |
| const userlines = getUserLines(); | |
| const userbuttons = [{ | |
| id: 'message', | |
| wenku: true, | |
| index: -2, | |
| element: $(userarea, 'a[onclick^="openDialog(\'/newmessage.php?"]') | |
| }, { | |
| id: 'detail', | |
| wenku: true, | |
| index: -1, | |
| element: $(userarea, `a[href^="https://${ location.host }/userpage.php?"]:not(strong > a)`), | |
| }]; | |
| const title = $(table, 'td:last-of-type > div:nth-of-type(1) > strong'); | |
| const floorbuttons = getFloorButtons(); | |
| const metaarea = $(table, 'td:last-of-type > div:nth-of-type(2)'); | |
| const content = $(table, 'td:last-of-type > div:nth-of-type(3)'); | |
| return { | |
| root: table, | |
| userarea, contentarea, avatar, | |
| userlink, userbuttons, userlines, | |
| title, floorbuttons, metaarea, | |
| content, | |
| } | |
| function getUserLines() { | |
| // 获取userlink后的第index个textnode的方法,index从1开始 | |
| const getTextNode = index => { | |
| let elm = userlink.parentElement; | |
| for (let i = 0; i < index; i++) { | |
| elm = elm.nextElementSibling; | |
| } | |
| return elm.nextSibling; | |
| } | |
| const getText = index => getTextNode(index).nodeValue.trim(); | |
| /** @type {FloorUserLine[]} */ | |
| const lines = [{ | |
| id: 'type', | |
| wenku: true, | |
| element: getTextNode(1) | |
| }, { | |
| id: 'level', | |
| wenku: true, | |
| element: getTextNode(2) | |
| }, { | |
| id: 'jointime', | |
| wenku: true, | |
| element: getTextNode(3) | |
| }, { | |
| id: 'experience', | |
| wenku: true, | |
| element: getTextNode(4) | |
| }, { | |
| id: 'credit', | |
| wenku: true, | |
| element: getTextNode(5) | |
| }]; | |
| return lines; | |
| } | |
| function getFloorButtons() { | |
| const floorbuttons = [{ | |
| id: 'link', | |
| wenku: true, | |
| element: $(table, 'td:last-of-type > div:nth-of-type(2) > a[href^="#yid"]'), | |
| }]; | |
| const edit = $(table, 'td:last-of-type > div:nth-of-type(2) > a[href*="/modules/article/reviewedit.php?yid="]'); | |
| edit && floorbuttons.push({ | |
| id: 'edit', | |
| wenku: true, | |
| index: -1, | |
| element: edit, | |
| }); | |
| return floorbuttons; | |
| } | |
| } | |
| /** | |
| * 从楼层元素对象解析楼层数据 | |
| * 仅可解析未经修改的Wenku自带DOM | |
| * @param {FloorElement} element - 楼层元素对象 | |
| * @param {Document} doc - 所在Document文档 | |
| * @returns {FloorData} | |
| */ | |
| function parseData(element, doc) { | |
| const getLineText = line_id => element.userlines.find(l => l.id === line_id).element.nodeValue.trim(); | |
| /** @type {FloorUser} */ | |
| const user = { | |
| avatar: element.avatar.src, | |
| name: element.userlink.innerText, | |
| id: new URL(element.userlink.href).searchParams.get('uid'), | |
| type: utils.getUserType(getLineText('type')), | |
| level: utils.getUserLevel(getLineText('level')), | |
| jointime: new Date(getLineText('jointime').match(/\d+-\d+-\d+/)[0]).getTime(), | |
| experience: parseInt(getLineText('experience').match(/\d+/)[0], 10), | |
| credit: parseInt(getLineText('credit').match(/\d+/)[0], 10), | |
| }; | |
| const title = element.title.innerText; | |
| //const content = parseContent(element.content); | |
| const content = bbcode.html2bbcode(element.content); | |
| const link_elm = element.floorbuttons.find(b => b.id === 'link').element; | |
| const last_floor_button = element.floorbuttons[element.floorbuttons.length-1].element; | |
| const time = new Date(last_floor_button.previousSibling.nodeValue.match(/\d+-\d+-\d+ +\d+:\d+:\d+/)[0]).getTime(); | |
| const url = getFloorUrl(link_elm); | |
| const rid = parseInt(new URLSearchParams(link_elm.search).get('rid'), 10); | |
| const yid = parseInt(link_elm.hash.match(/\d+/)[0], 10); | |
| const number = parseInt(link_elm.innerText.match(/\d+/)[0], 10); | |
| const highlight = false; | |
| return { user, title, content, time, url, rid, yid, number, highlight }; | |
| /** @param {HTMLAnchorElement} link_elm */ | |
| function getFloorUrl(link_elm) { | |
| const obj_url = new URL(link_elm.href); | |
| obj_url.searchParams.set('page', $(doc, '#pagelink > strong').innerText.trim()); | |
| return obj_url.href; | |
| } | |
| } | |
| // Get floor content by BBCode format (content only, no title) | |
| // Argv: <div> content element | |
| /** | |
| * 从正文内容div的DOM结构中解析bbcode源代码 | |
| * @param {HTMLDivElement} content_elm | |
| * @param {boolean} [use_img_tag=false] | |
| * @returns {string} | |
| */ | |
| function parseContent(content_elm, use_img_tag=false) { | |
| const subNodes = content_elm.childNodes; | |
| let content = ''; | |
| for (const node of subNodes) { | |
| const type = node.nodeName; | |
| switch (type) { | |
| case '#text': | |
| // Prevent 'Quote:' repeat | |
| content += node.data.replace(/^\s*Quote:\s*$/, ' '); | |
| break; | |
| case 'IMG': | |
| // wenku8 has forbidden [img] tag for secure reason (preventing CSRF) | |
| //content += '[img]S[/img]'.replace('S', node.src); | |
| content += use_img_tag ? '[img]S[/img]'.replace('S', node.src) : ' S '.replace('S', node.src); | |
| break; | |
| case 'A': | |
| content += '[url=U]T[/url]'.replace('U', node.getAttribute('href')).replace('T', parseContent(node)); | |
| break; | |
| case 'BR': | |
| // no need to add \n, because \n will be preserved in #text nodes | |
| //content += '\n'; | |
| break; | |
| case 'DIV': | |
| if (node.classList.contains('jieqiQuote')) { | |
| content += getTagedSubcontent('quote', node); | |
| } else if (node.classList.contains('jieqiCode')) { | |
| content += getTagedSubcontent('code', node); | |
| } else if (node.classList.contains('divimage')) { | |
| content += parseContent(node, use_img_tag); | |
| } else { | |
| content += parseContent(node, use_img_tag); | |
| } | |
| break; | |
| case 'CODE': content += parseContent(node, use_img_tag); break; // Just ignore | |
| case 'PRE': content += parseContent(node, use_img_tag); break; // Just ignore | |
| case 'SPAN': content += getFontedSubcontent(node); break; // Size and color | |
| case 'P': content += getFontedSubcontent(node); break; // Text Align | |
| case 'B': content += getTagedSubcontent('b', node); break; | |
| case 'I': content += getTagedSubcontent('i', node); break; | |
| case 'U': content += getTagedSubcontent('u', node); break; | |
| case 'DEL': content += getTagedSubcontent('d', node); break; | |
| default: content += parseContent(node, use_img_tag); break; | |
| } | |
| } | |
| return content; | |
| function getTagedSubcontent(tag, node) { | |
| const subContent = parseContent(node, use_img_tag); | |
| return '[{T}]{S}[/{T}]'.replaceAll('{T}', tag).replaceAll('{S}', subContent); | |
| } | |
| function getFontedSubcontent(node) { | |
| let tag, value; | |
| let strSize = node.style.fontSize.match(/\d+/); | |
| let strColor = node.style.color; | |
| let strAlign = node.align; | |
| strSize = strSize ? strSize[0] : null; | |
| strColor = strColor ? rgbToHex.apply(null, strColor.match(/\d+/g)) : null; | |
| tag = tag || (strSize ? 'size' : null); | |
| tag = tag || (strColor ? 'color' : null); | |
| tag = tag || (strAlign ? 'align' : null); | |
| value = value || strSize || null; | |
| value = value || strColor || null; | |
| value = value || strAlign || null; | |
| const subContent = parseContent(node, use_img_tag); | |
| if (tag && value) { | |
| return '[{T}={V}]{S}[/{T}]'.replaceAll('{T}', tag).replaceAll('{V}', value).replaceAll('{S}', subContent); | |
| } else { | |
| return subContent; | |
| } | |
| function rgbToHex(r, g, b) {return ((r << 16) | (g << 8) | b).toString(16).padStart('0', 6);} | |
| } | |
| } | |
| /** | |
| * 根据id获取一个楼层操作按钮 | |
| * @param {Floor} floor | |
| * @param {string} id | |
| * @returns {FloorButton | null} | |
| */ | |
| function getFloorButton(floor, id) { | |
| return floor.element.floorbuttons.find(b => b.id === id); | |
| } | |
| /** | |
| * 根据id获取一个用户操作按钮 | |
| * @param {Floor} floor | |
| * @param {string} id | |
| * @returns {FloorButton | null} | |
| */ | |
| function getUserButton(floor, id) { | |
| return floor.element.userbuttons.find(b => b.id === id); | |
| } | |
| /** | |
| * 根据id获取一个用户信息行 | |
| * @param {Floor} floor | |
| * @param {string} id | |
| * @returns {FloorUserLine | null} | |
| */ | |
| function getUserLine(floor, id) { | |
| return floor.element.userlines.find(l => l.id === id); | |
| } | |
| return { | |
| parse, parseAll, parseContent, | |
| getFloorButton, getUserButton, getUserLine, | |
| } | |
| } | |
| }, | |
| transformer: { | |
| desc: '楼层内容修改器', | |
| dependencies: 'parser', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.transformer.func>>} transformer */ | |
| func() { | |
| /** @type {parser} */ | |
| const parser = pool.require('parser'); | |
| /** | |
| * 在楼层右上角按钮处新增一个按钮 | |
| * @param {Floor} floor | |
| * @param {Object} options | |
| * @param {string} options.id | |
| * @param {string} options.label | |
| * @param {number} options.index - 按钮排序位置,仅在非文库自带按钮间排序,文库按钮均在非文库按钮之前 | |
| * @param {function} [options.callback] - 按钮点击回调,和element二选一 | |
| * @param {function} [options.element] - 按钮元素,和callback二选一 | |
| * @returns {FloorButton} | |
| */ | |
| function addFloorButton(floor, { id, label, index, callback=null, element=null }) { | |
| const floorbuttons = floor.element.floorbuttons; | |
| // 创建按钮元素 | |
| const elm = element ?? $$CrE({ | |
| tagName: 'span', | |
| props: { | |
| innerText: label | |
| }, | |
| listeners: [['click', e => callback()]] | |
| }); | |
| elm.style.color = 'var(--q-primary)'; | |
| elm.style.cursor = 'pointer'; | |
| // 记录当前页面上最右侧(第一个)按钮以及其右侧#text | |
| const first_button = floorbuttons[0]; | |
| const first_button_sibling = first_button.element.nextSibling; | |
| // 添加按钮数据并按照index排序 | |
| const button = { | |
| id, | |
| wenku: false, | |
| element: elm | |
| }; | |
| floorbuttons.push(button); | |
| floorbuttons.sort((b1, b2) => b1.index - b2.index); | |
| // 将所有按钮按照新顺序重新添加到页面 | |
| floorbuttons.forEach(btn => { | |
| // 依次移除所有按钮 | |
| if (btn.element.closest('body') === document.body) { | |
| // 当按钮不是先前的最右侧按钮时,把右边的" | "也移除掉 | |
| btn !== first_button && btn.element.nextSibling.remove(); | |
| btn.element.remove(); | |
| } | |
| }); | |
| floorbuttons.forEach((btn, i) => { | |
| if (i === 0) { | |
| // 第一个按钮添加到原先最右侧按钮右边的#text左侧 | |
| first_button_sibling.before(btn.element); | |
| } else { | |
| // 后续按钮依次添加到上一个按钮之前(左侧) | |
| const last_floor_button = floorbuttons[i-1]; | |
| last_floor_button.element.before(btn.element); | |
| } | |
| // 当不是第一个(最右侧)按钮时,右侧添加" | " | |
| i > 0 && btn.element.after(' | '); | |
| }); | |
| return button; | |
| } | |
| /** | |
| * 从楼层中移除一个按钮 | |
| * @param {Floor} floor - 楼层 | |
| * @param {string} id - 按钮ID | |
| * @returns {FloorButton | null} 被移除的按钮;null(不存在此id对应的按钮) | |
| */ | |
| function removeFloorButton(floor, id) { | |
| const floorbuttons = floor.element.floorbuttons; | |
| const index = floorbuttons.findIndex(btn => btn.id === id); | |
| if (index > -1) { | |
| const button = floorbuttons.splice(index, 1)[0]; | |
| index !== 0 && button.element.nextSibling.remove(); | |
| button.element.remove(); | |
| return button; | |
| } else { | |
| return null; | |
| } | |
| } | |
| /** | |
| * 在楼层左侧用户区下方新增一个按钮 | |
| * @param {Floor} floor | |
| * @param {Object} options | |
| * @param {string} options.id | |
| * @param {string} [options.label] - 按钮文字,和element二选一 | |
| * @param {number} options.index - 按钮排序位置,仅在非文库自带按钮间排序,文库按钮均在非文库按钮之前 | |
| * @param {function} [options.callback] - 按钮点击回调,和element二选一 | |
| * @param {function} [options.element] - 按钮元素,和callback二选一 | |
| * @returns {FloorButton} | |
| */ | |
| function addUserButton(floor, { id, label = null, index, callback=null, element=null }) { | |
| // 创建/装饰按钮元素 | |
| /** @type {HTMLDivElement} */ | |
| const container = floor.element.avatar.parentElement; | |
| const elm = element ?? $$CrE({ | |
| tagName: 'span', | |
| props: { innerText: label }, | |
| listeners: [['click', e => callback()]] | |
| }); | |
| elm.style.color = 'var(--q-primary)'; | |
| elm.style.cursor = 'pointer'; | |
| // 添加按钮数据,按照index重新排序 | |
| const button = { | |
| id, | |
| wenku: false, | |
| index, | |
| element: elm | |
| }; | |
| floor.element.userbuttons.push(button); | |
| floor.element.userbuttons.sort((b1, b2) => b1.index - b2.index); | |
| // 将所有按钮按照新顺序重新添加到页面 | |
| const userbuttons = floor.element.userbuttons; | |
| userbuttons.forEach(btn => { | |
| if (btn.element.closest('body') === document.body) { | |
| const prev = btn.element.previousSibling; | |
| ['#text', 'BR'].includes(prev.nodeName) && prev.remove(); | |
| btn.element.remove(); | |
| } | |
| }); | |
| userbuttons.forEach((btn, i) => { | |
| const number = i + 1; | |
| if (number % 2 === 1) { | |
| // 每行第一个 | |
| i !== 0 && container.append($CrE('br')); | |
| container.append(btn.element); | |
| } else { | |
| // 每行第二个 | |
| container.append(' | '); | |
| container.append(btn.element); | |
| } | |
| }); | |
| return button; | |
| } | |
| /** | |
| * 添加一行内容到指定楼层的左侧用户区域 | |
| * @param {Floor} floor - 添加到的楼层 | |
| * @param {Object} options | |
| * @param {string} options.id - 全局唯一,信息行id | |
| * @param {Node | string} options.line - 添加的内容,字符串将转换为文本节点添加 | |
| * @param {string} options.base - 一个现有信息行的id,和 position 配合使用,添加到该行的前面或者后面 | |
| * @param {'before' | 'after'} options.position - 添加的位置,前面还是后面 | |
| */ | |
| function addUserLine(floor, { id, line, base, position }) { | |
| // 将字符串line转换为TextNode | |
| if (typeof line === 'string') { | |
| line = document.createTextNode(line); | |
| } | |
| // 插入到指定行的指定位置 | |
| const base_line = parser.getUserLine(floor, base); | |
| switch (position) { | |
| case 'before': { | |
| base_line.element.before(line); | |
| base_line.element.before($CrE('br')); | |
| break; | |
| } | |
| case 'after': { | |
| base_line.element.after(line); | |
| base_line.element.after($CrE('br')); | |
| break; | |
| } | |
| } | |
| // 添加到楼层行数据中 | |
| /** @type {FloorUserLine} */ | |
| const userline = { | |
| id, | |
| wenku: false, | |
| element: line, | |
| }; | |
| let index = floor.element.userlines.indexOf(base_line); | |
| position === 'after' && index++; | |
| floor.element.userlines.splice(index, 0, userline); | |
| } | |
| /** | |
| * 更新指定楼层一个已有用户信息行的内容 | |
| * @param {Floor} floor - 更新的楼层 | |
| * @param {string} id - 信息行id | |
| * @param {Node | string} line - 新的信息行内容,字符串将转换为文本节点 | |
| */ | |
| function updateLine(floor, id, line) { | |
| // 将字符串line转换为TextNode | |
| if (typeof line === 'string') { | |
| line = document.createTextNode(line); | |
| } | |
| const userline = parser.getUserLine(floor, id); | |
| const previous_node = userline.element.previousSibling; | |
| previous_node.after(line); | |
| userline.element.remove(); | |
| userline.element = line; | |
| } | |
| addStyle(` | |
| .plus-highlight { | |
| box-shadow: 0 0 10px 1px #75b1df; | |
| } | |
| .plus-darkmode .plus-highlight { | |
| box-shadow: 0 0 10px 1px #0d688b; | |
| } | |
| `, 'plus-review-transformer') | |
| /** | |
| * 对楼层应用高亮效果 | |
| * @param {Floor} floor | |
| */ | |
| function applyHighlight(floor) { | |
| floor.data.highlight = true; | |
| floor.element.root.classList.add('plus-highlight'); | |
| $AEL(floor.element.root, 'click', e => clearHighlight(floor), { once: true }); | |
| } | |
| /** | |
| * 对楼层清除高亮效果 | |
| * @param {Floor} floor | |
| */ | |
| function clearHighlight(floor) { | |
| floor.data.highlight = false; | |
| floor.element.root.classList.remove('plus-highlight'); | |
| } | |
| return { | |
| addFloorButton, removeFloorButton, addUserButton, addUserLine, updateLine, | |
| applyHighlight, clearHighlight, | |
| } | |
| } | |
| }, | |
| updater: { | |
| desc: '从服务器获取实时评论页面,更新页面内容', | |
| dependencies: ['parser', 'transformer'], | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.updater.func>>} updater */ | |
| async func() { | |
| /** @type {parser} */ | |
| const parser = pool.require('parser'); | |
| /** @type {transformer} */ | |
| const transformer = pool.require('transformer'); | |
| /** | |
| * 更新页面时用的提示UI | |
| * @satisfies {Record<string, { start: () => void, end: (updated: number) => void, error: (err: Error) => void }>} | |
| */ | |
| const UI = { | |
| loading: { | |
| start() { | |
| Quasar.Loading.show({ message: CONST.Text.Review.FloorManager.UpdatingFloors }); | |
| }, | |
| end() { | |
| Quasar.Loading.hide(); | |
| }, | |
| error(err) { | |
| Quasar.Loading.hide(); | |
| Quasar.Notify.create({ | |
| type: 'error', | |
| message: CONST.Text.Review.FloorManager.FloorUpdateError, | |
| caption: CONST.Text.Review.FloorManager.FloorUpdateErrorCaption, | |
| group: 'review.update_floor_error' | |
| }); | |
| require('debugging', true).then( | |
| /** @param {debugging} debugging */ | |
| debugging => debugging.saveError({ | |
| type: 'fetch', | |
| error: err, | |
| info: null, | |
| }) | |
| ) | |
| } | |
| }, | |
| notify: { | |
| start() { | |
| Quasar.Notify.create({ | |
| type: 'info', | |
| message: CONST.Text.Review.FloorManager.UpdatingFloors, | |
| group: 'review.update_floor' | |
| }); | |
| }, | |
| end(updated) { | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: CONST.Text.Review.FloorManager.FloorUpdated, | |
| caption: replaceText( | |
| CONST.Text.Review.FloorManager.FloorUpdatedCaption, | |
| { '{Updated}': updated }, | |
| ), | |
| timeout: 1000, | |
| group: 'review.update_floor', | |
| }); | |
| }, | |
| error(err) { | |
| Quasar.Notify.create({ | |
| type: 'error', | |
| message: CONST.Text.Review.FloorManager.FloorUpdateError, | |
| caption: CONST.Text.Review.FloorManager.FloorUpdateErrorCaption, | |
| group: 'review.update_floor_error' | |
| }); | |
| require('debugging', true).then( | |
| /** @param {debugging} debugging */ | |
| debugging => debugging.saveError({ | |
| type: 'fetch', | |
| error: err, | |
| info: null, | |
| }) | |
| ) | |
| } | |
| } | |
| }; | |
| /** | |
| * 获取一个评论页面,并解析 | |
| * @param {number} rid | |
| * @param {number | 'last'} [page] | |
| * @returns {Promise<{ floors: Floor[], pagelink: HTMLDivElement }>} | |
| */ | |
| function fetch(rid, page, retry=2) { | |
| const { promise, resolve, reject } = Promise.withResolvers(); | |
| try { | |
| utils.requestDocument({ | |
| method: 'GET', | |
| url: `/modules/article/reviewshow.php?rid=${rid}&page=${page ?? 1}`, | |
| onerror: onError, | |
| }).then(doc => { | |
| const floors = parser.parseAll(doc); | |
| const pagelink = $(doc, '#pagelink'); | |
| resolve({ floors, pagelink }); | |
| }); | |
| } catch(err) { | |
| onError(err); | |
| } | |
| return promise; | |
| async function onError(err) { | |
| /** @type {logger} */ | |
| const logger = await require('logger', true); | |
| if (retry-- > 0) { | |
| logger.warn('Warn', 'review.FloorManager.updater.fetch: Retrying...'); | |
| await fetch(rid, page, retry).then(result => resolve(result)).catch(err => reject(err)); | |
| } else { | |
| logger.warn('Error', 'review.FloorManager.updater.fetch: Maximum error retry attempts reached'); | |
| reject(err); | |
| } | |
| } | |
| } | |
| /** | |
| * 从文库服务器获取当前书评页面的最新版本,并更新到页面中,同时也更新floors全局实例 | |
| * 注意:只有在楼层标题或内容有所改变时,才会更新对应楼层 | |
| * @param {keyof typeof UI} [ui='notify'] - 采用什么UI提示用户页面楼层正在更新;默认"notify" | |
| * @param {number | 'last'} [page] - 需要加载(更新到)的页面页码,默认为当前页码;默认当前页码;注意:这里即使填写了"last",最终url也会显示对应的数字格式的页码,而不是"page=last" | |
| * @param {boolean} [highlight=true] - 是否高亮发生了更改的楼层;默认为true | |
| * @param {'push' | 'replace' | 'none'} [state='replace'] - 在页码改变时,是添加新浏览历史、修改现有浏览状态还是不改变浏览历史和状态;默认"replace"(修改现有);注意:当页码没有改变时,无论填写什么,都既不会添加新浏览记录,又不会改变现有浏览记录 | |
| */ | |
| async function update(ui = 'notify', page=null, highlight=true, state='replace') { | |
| UI[ui].start(); | |
| // 获取最新的页面楼层 | |
| const search = new URLSearchParams(location.search); | |
| const rid = parseInt(search.get('rid'), 10); | |
| const cur_page = parseInt($('#pagelink > strong').innerText.trim(), 10); | |
| page = page ?? cur_page; | |
| /** @type {Floor[]} */ | |
| let new_floors; | |
| /** @type {HTMLDivElement} */ | |
| let new_pagelink; | |
| await (fetch(rid, page).then(({ floors, pagelink }) => { | |
| new_floors = floors; | |
| new_pagelink = pagelink; | |
| }).catch(err => { | |
| UI[ui].error(err); | |
| throw err; | |
| })); | |
| // 旧楼层列表比新楼层列表长时,去除旧楼层尾部多出来的楼层 | |
| if (floors.length > new_floors.length) { | |
| for (let i = new_floors.length; i < floors.length; i++) { | |
| floors[i].element.root.remove(); | |
| } | |
| floors.splice(new_floors.length, floors.length - new_floors.length); | |
| } | |
| // 和页面现有楼层比对,对有内容更新的楼层进行更新 | |
| const updated_floors = []; | |
| new_floors.forEach((new_floor, i) => { | |
| const old_floor = floors[i]; | |
| // 跳过无内容更新的楼层 | |
| if ( | |
| old_floor && | |
| old_floor.data.number === new_floor.data.number && | |
| old_floor.data.content === new_floor.data.content && | |
| old_floor.data.title === new_floor.data.title | |
| ) { return; } | |
| // 更新楼层 | |
| if (old_floor) { | |
| old_floor.element.root.before(new_floor.element.root); | |
| old_floor.element.root.remove(); | |
| } else { | |
| // 新增楼层 | |
| floors[floors.length-1].element.root.after(new_floor.element.root); | |
| } | |
| // 对新楼层应用高亮效果 | |
| highlight && transformer.applyHighlight(new_floor); | |
| // 更新楼层实例数据 | |
| old_floor ? | |
| floors.splice(floors.indexOf(old_floor), 1, new_floor) : | |
| floors.push(new_floor); | |
| // 记录新楼层 | |
| updated_floors.push(new_floor); | |
| }); | |
| // 同时更新一下页脚翻页指示器 | |
| const old_pagelink = $('#pagelink'); | |
| old_pagelink.before(new_pagelink); | |
| old_pagelink.remove(); | |
| // 如果页码有改变,添加/改变浏览历史 | |
| const num_page = page === 'last' ? parseInt($('#pagelink > strong').innerText.trim(), 10) : page; | |
| num_page !== cur_page && ({ | |
| 'push': history.pushState.bind(history), | |
| 'replace': history.replaceState.bind(history), | |
| 'none': () => {}, | |
| })[state](null, '', `/modules/article/reviewshow.php?rid=${rid}&page=${num_page}`); | |
| // 广播楼层更新事件 | |
| messager.dispatchEvent(new CustomEvent('update', { | |
| detail: { | |
| floors: updated_floors, | |
| } | |
| })); | |
| UI[ui].end(updated_floors.length); | |
| } | |
| return { fetch, update, }; | |
| } | |
| }, | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs); | |
| await promise; | |
| /** @type {parser} */ | |
| const parser = pool.require('parser'); | |
| const floors = parser.parseAll(); | |
| /** | |
| * 将传入的方法应用于全部的Floor,包括一开始就在页面上的和后来通过更新等方式添加到页面上的 | |
| * @param {(floor: Floor) => any} func | |
| */ | |
| function applyToAllFloors(func) { | |
| floors.forEach(floor => func(floor)); | |
| $AEL(messager, 'update', e => | |
| e.detail.floors.forEach(floor => func(floor)) | |
| ); | |
| } | |
| return { | |
| /** 全局唯一 floors 数据实例,一切涉及楼层的操作都应围绕此数据实例进行 */ | |
| floors, | |
| /** @type {parser} */ | |
| parser: pool.require('parser'), | |
| /** @type {transformer} */ | |
| transformer: pool.require('transformer'), | |
| /** @type {updater} */ | |
| updater: pool.require('updater'), | |
| applyToAllFloors, | |
| } | |
| } | |
| }, | |
| citing: { | |
| desc: '楼层引用功能', | |
| dependencies: 'FloorManager', | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.citing.func>>} citing */ | |
| func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| GM_getValue = utils.defaultedGet({ | |
| no_content: false, | |
| pangu: true, | |
| select: false, | |
| }, GM_getValue); | |
| configs.registerSettings('review', [{ | |
| type: 'boolean', | |
| label: CONST.Text.Review.Settings.Pangu, | |
| caption: CONST.Text.Review.Settings.PanguCaption, | |
| key: 'pangu', | |
| get() { return GM_getValue('pangu'); }, | |
| set(val) { return GM_setValue('pangu', val); }, | |
| }, { | |
| type: 'boolean', | |
| label: CONST.Text.Review.Settings.NoContent, | |
| caption: CONST.Text.Review.Settings.NoContentCaption, | |
| key: 'no_content', | |
| get() { return GM_getValue('no_content'); }, | |
| set(val) { return GM_setValue('no_content', val); }, | |
| }, { | |
| type: 'boolean', | |
| label: CONST.Text.Review.Settings.Select, | |
| caption: CONST.Text.Review.Settings.SelectCaption, | |
| key: 'select', | |
| get() { return GM_getValue('select'); }, | |
| set(val) { return GM_setValue('select', val); }, | |
| }], GM_addValueChangeListener); | |
| /** @type {FloorManager} */ | |
| const FloorManager = pool.require('FloorManager'); | |
| // 为每个楼层添加引用按钮 | |
| FloorManager.applyToAllFloors(addCiteButton); | |
| // 延迟100毫秒记录文档选中区域,点击"引用"按钮时可以取得点击前用户选中的范围 | |
| /** @type {Range} */ | |
| let range; | |
| $AEL(document, 'selectionchange', e => setTimeout(() => { | |
| const selection = getSelection(); | |
| range = selection.isCollapsed ? null : selection.getRangeAt(0); | |
| }, 100)); | |
| /** | |
| * 为给定楼层添加引用按钮 | |
| * @param {Floor} floor | |
| */ | |
| function addCiteButton(floor) { | |
| doAdd(); | |
| configs.registerUpdateCallback('review', { | |
| no_content: () => doAdd(), | |
| }); | |
| function doAdd() { | |
| /** @type {boolean} */ | |
| const no_content = GM_getValue('no_content'); | |
| const Cite = CONST.Text.Review.Cite; | |
| // 移除旧引用按钮(如果存在) | |
| const old_button = FloorManager.parser.getFloorButton(floor, 'cite'); | |
| old_button && FloorManager.transformer.removeFloorButton(floor, 'cite'); | |
| // 创建按钮及其鼠标悬浮内容 | |
| const button = FloorManager.transformer.addFloorButton(floor, { | |
| id: 'cite', | |
| label: Cite.Cite, | |
| index: 1, | |
| callback: () => cite(floor) | |
| }); | |
| const alt_button = $$CrE({ | |
| tagName: 'span', | |
| props: { | |
| innerText: no_content ? Cite.CiteAlt.FullContent : Cite.CiteAlt.NumberOnly | |
| }, | |
| styles: { | |
| cursor: 'pointer', | |
| }, | |
| classes: ['text-primary'], | |
| listeners: [['click', e => cite(floor, !no_content)]], | |
| }); | |
| const container = $$CrE({ | |
| tagName: 'span', | |
| styles: { | |
| cursor: 'default', | |
| userSelect: 'none', | |
| }, | |
| }); | |
| container.append( | |
| new Text(Cite.CiteAltPrefix), | |
| alt_button | |
| ); | |
| tippy(button.element, { | |
| content: container, | |
| interactive: true, | |
| theme: 'lightdark', | |
| }); | |
| } | |
| } | |
| /** | |
| * 引用某个楼层到回帖输入框中 | |
| * @param {Floor} floor - 引用的楼层 | |
| * @param {boolean} [no_content] - 是否仅引用楼号,省略则使用储存的配置 | |
| * @param {boolean} [pangu] - 是否保证和周围文字之间有且仅有一个空格,省略则使用储存的配置 | |
| * @param {boolean} [select] - 是否选中引用部分文字,省略则使用储存的配置 | |
| */ | |
| function cite(floor, no_content=null, pangu=null, select=null) { | |
| no_content = no_content ?? GM_getValue('no_content'); | |
| pangu = pangu ?? GM_getValue('pangu'); | |
| select = select ?? GM_getValue('select'); | |
| /** @type {HTMLTextAreaElement | null} */ | |
| const textarea = $('#pcontent'); | |
| if (!textarea) { return; } | |
| // 获取引用内容 | |
| let code; | |
| if ( | |
| // 用户已选中部分内容 | |
| range && !range.collapsed && | |
| // 用户选中的内容都在当前楼层正文区内 | |
| floor.element.content.contains(range.commonAncestorContainer) | |
| ) { | |
| // 引用用户选中部分内容 | |
| const fragment = range.cloneContents(); | |
| const container = $CrE('div'); | |
| container.appendChild(fragment); | |
| code = bbcode.html2bbcode(container); | |
| } else { | |
| // 引用楼层全部内容 | |
| code = floor.data.content; | |
| } | |
| // 包装引用bbcode | |
| const full_code = code = no_content ? | |
| `[url=${floor.data.url}]#${floor.data.number}[/url]` : | |
| `[url=${floor.data.url}]#${floor.data.number}[/url] [quote]${code}[/quote]\n`; | |
| // 插入到输入框 | |
| utils.insertText(textarea, full_code, pangu, select); | |
| // 自动聚焦到输入框的同时平滑滚动到输入框位置 | |
| // .focus会自动跳转到元素位置,因此需要先复位到.focus前再开始平滑滚动 | |
| // 虽然有 preventScroll 选项,但是这个选项在安卓上似乎不可用 | |
| const [orig_x, orig_y] = [window.scrollX, window.scrollY]; | |
| textarea.focus({ preventScroll: true }); | |
| window.scroll(orig_x, orig_y); | |
| textarea.scrollIntoView({ behavior: 'smooth' }); | |
| } | |
| return { | |
| /** @type {boolean} */ | |
| get pangu() { return GM_getValue('pangu'); }, | |
| set pangu(val) { return GM_setValue('pangu', val); }, | |
| /** @type {boolean} */ | |
| get no_content() { return GM_getValue('no_content'); }, | |
| set no_content(val) { return GM_setValue('no_content', val); }, | |
| /** @type {boolean} */ | |
| get select() { return GM_getValue('select'); }, | |
| set select(val) { return GM_setValue('select', val); } | |
| }; | |
| } | |
| }, | |
| floorjump: { | |
| desc: '点击页面内楼层链接,直接跳转到页面位置,而不是重新加载页面到该位置', | |
| dependencies: 'FloorManager', | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.floorjump.func>>} floorjump */ | |
| func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| GM_getValue = utils.defaultedGet({ | |
| jump: true | |
| }, GM_getValue); | |
| /** @type {FloorManager} */ | |
| const FloorManager = pool.require('FloorManager'); | |
| const floors = FloorManager.floors; | |
| // 拦截<a>点击事件,根据设置决定是否跳转 | |
| const content = $('#content'); | |
| $AEL(content, 'click', e => { | |
| // 检查是否开启跳转功能 | |
| if (!GM_getValue('jump')) { return; } | |
| // 按下Ctrl/Meta/Shift键代表用户显式指定在新标签页/窗口打开,不执行跳转 | |
| if (e.ctrlKey || e.shiftKey || e.metaKey) { return; } | |
| // 检查是否点击到了一个指向楼层的链接 | |
| /** @type {null | HTMLAnchorElement} */ | |
| const a = e.target.closest('a[href*="#yid"]'); | |
| if ( | |
| !a || | |
| a.pathname !== '/modules/article/reviewshow.php' || | |
| !/^#yid\d+$/.test(a.hash) | |
| ) { return; } | |
| // 检查链接是否在某楼层正文内 | |
| if (floors.every(floor => !floor.element.content.contains(a))) { return; } | |
| // 尝试跳转,当目标yid楼层在页面内时会跳转成功 | |
| // 前面判断过a.hash符合yid\d+的格式,可以直接match取值 | |
| const yid = parseInt(a.hash.match(/\d+/)[0], 10); | |
| const success = jump(yid); | |
| success && e.preventDefault(); | |
| }); | |
| /** | |
| * 跳转到页面内某楼层,成功返回true,失败返回false | |
| * @param {number} yid - 跳转目标楼层yid | |
| * @param {boolean} [pushState=true] - 是否添加浏览历史记录,默认为true | |
| * @returns {boolean} | |
| */ | |
| function jump(yid, pushState = true) { | |
| // 检查目标楼层是否在页面内 | |
| /** @type {Floor} */ | |
| const floor = floors.find(floor => floor.data.yid === yid); | |
| if (!floor) { return false; } | |
| // 检查通过,跳转 | |
| floor.element.root.scrollIntoView({ behavior: 'smooth' }); | |
| // 添加浏览历史记录 | |
| if (pushState) { | |
| const url = new URL(floor.data.url); | |
| const path = `${url.pathname}${url.search}${url.hash}`; | |
| history.pushState(null, '', path); | |
| } | |
| return true; | |
| } | |
| // 注册设置项 | |
| configs.registerSettings('review', [{ | |
| type: 'boolean', | |
| label: CONST.Text.Review.Settings.FloorJump, | |
| caption: CONST.Text.Review.Settings.FloorJumpCaption, | |
| key: 'floorjump', | |
| get() { return GM_getValue('jump'); }, | |
| set(val) { return GM_setValue('jump', val); }, | |
| }], GM_addValueChangeListener); | |
| return { | |
| get enabled() { return GM_getValue('jump'); }, | |
| set enabled(val) { GM_setValue('jump', val); }, | |
| jump, | |
| }; | |
| } | |
| }, | |
| pagejump: { | |
| desc: '点击右下角页码切换,直接页面内更新,而不是重建加载页面到该页码', | |
| dependencies: ['FloorManager'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.pagejump.func>>} pagejump */ | |
| func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {FloorManager} */ | |
| const FloorManager = pool.require('FloorManager'); | |
| GM_getValue = utils.defaultedGet({ | |
| jump: true, | |
| }, GM_getValue); | |
| // 拦截#pagelink > a点击事件,根据设置决定是否页面内更新 | |
| // 因为 #pagelink 会随着页面内更新而更新改变,所以要把事件监听器添加到父元素上 | |
| const pagelink_parent = $('#pagelink').parentElement; | |
| $AEL(pagelink_parent, 'click', e => { | |
| // 检查是否开启跳转功能 | |
| if (!GM_getValue('jump')) { return; } | |
| // 按下Ctrl/Meta/Shift键代表用户显式指定在新标签页/窗口打开,不执行跳转 | |
| if (e.ctrlKey || e.shiftKey || e.metaKey) { return; } | |
| // 检查是否点击到了一个指向新页码的链接 | |
| /** @type {null | HTMLAnchorElement} */ | |
| const a = e.target.closest('a[href^="/modules/article/reviewshow.php"]'); | |
| if ( | |
| !a || | |
| a.pathname !== '/modules/article/reviewshow.php' | |
| ) { return; } | |
| // 页面内更新 | |
| e.preventDefault(); | |
| const search = new URLSearchParams(a.search); | |
| const page = parseInt(search.get('page'), 10); | |
| FloorManager.updater.update('loading', page, false, 'push'); | |
| }); | |
| // 注册设置项 | |
| configs.registerSettings('review', [{ | |
| type: 'boolean', | |
| label: CONST.Text.Review.Settings.PageJump, | |
| caption: CONST.Text.Review.Settings.PageJumpCaption, | |
| key: 'pagejump', | |
| get() { return GM_getValue('jump'); }, | |
| set(val) { return GM_setValue('jump', val); }, | |
| }], GM_addValueChangeListener); | |
| return { | |
| get enabled() { return GM_getValue('jump'); }, | |
| set enabled(val) { GM_setValue('jump', val); }, | |
| }; | |
| } | |
| }, | |
| popstate: { | |
| desc: '处理浏览器历史记录回退', | |
| dependencies: ['floorjump', 'pagejump'], | |
| func() { | |
| /** @type {FloorManager} */ | |
| const FloorManager = pool.require('FloorManager'); | |
| /** @type {floorjump} */ | |
| const floorjump = pool.require('floorjump'); | |
| /** @type {pagejump} */ | |
| const pagejump = pool.require('pagejump'); | |
| history.onPopstate(async e => { | |
| const old_url = new URL(e.old_url); | |
| const new_url = new URL(e.new_url); | |
| const same_review = old_url.searchParams.get('rid') === new_url.searchParams.get('rid'); | |
| const same_page = same_review && old_url.searchParams.get('page') === new_url.searchParams.get('page'); | |
| const same_floor = same_page && old_url.hash === new_url.hash; | |
| // 页面、楼层都未改变,不执行任何操作 | |
| if (same_floor) { return; } | |
| // 同一页面内不同楼层,直接滚动至该楼层 | |
| if (same_page) { | |
| // 只有当url中确实指定了yid时才跳转,否则无楼可跳,此时让浏览器默认行为处理即可 | |
| const str_yid = new_url.hash.match(/\d+/)?.[0]; | |
| if (floorjump.enabled && str_yid) { | |
| floorjump.jump(parseInt(str_yid, 10), false); | |
| e.preventDefault(); | |
| } | |
| return; | |
| } | |
| // 同一书评不同页面,动态更新到该页面,同时如有指定楼层,跳转到该楼层 | |
| if (same_review) { | |
| // 页面更新 | |
| const page = parseInt(new_url.searchParams.get('page') ?? '0', 10); | |
| pagejump.enabled ? | |
| await FloorManager.updater.update('loading', page, false, 'none') : | |
| location.reload(); | |
| // 楼层跳转 | |
| const str_yid = new_url.hash.match(/\d+/)?.[0]; | |
| // 这里注意楼层更新后会有一个QLoading加载遮罩渐变消失的动画,期间body不可滚动,需延迟一会等待动画完毕再进行跳转 | |
| floorjump.enabled && str_yid && setTimeout(() => | |
| floorjump.jump(parseInt(str_yid, 10), false), 500); | |
| return; | |
| } | |
| // 不同书评,刷新页面 | |
| location.reload(); | |
| }); | |
| }, | |
| }, | |
| replyinpage: { | |
| desc: '页面内免刷新发评论', | |
| detectDom: '.main.m_foot', | |
| dependencies: ['FloorManager'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.replyinpage.func>>} replyinpage */ | |
| async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {FloorManager} */ | |
| const FloorManager = pool.require('FloorManager'); | |
| GM_getValue = utils.defaultedGet({ | |
| enabled: true, | |
| fillblank: false, | |
| }, GM_getValue); | |
| configs.registerSettings('review', [{ | |
| type: 'boolean', | |
| label: CONST.Text.Review.Settings.ReplyInPage, | |
| caption: CONST.Text.Review.Settings.ReplyInPageCaption, | |
| key: 'replyinpage', | |
| get() { return GM_getValue('enabled'); }, | |
| set(val) { return GM_setValue('enabled', val); }, | |
| }, { | |
| type: 'boolean', | |
| label: CONST.Text.Review.Settings.FillBlank, | |
| caption: CONST.Text.Review.Settings.FillBlankCaption, | |
| key: 'fillblank', | |
| get() { return GM_getValue('fillblank'); }, | |
| set(val) { return GM_setValue('fillblank', val); }, | |
| }], GM_addValueChangeListener); | |
| const form = $('form[name="frmreview"]'); | |
| form && hookSubmit(form); | |
| /** | |
| * 将评论编辑器的表单提交改为ajax请求,并在请求完成后更新页面楼层 | |
| * @param {HTMLFormElement} form | |
| * @param {(form: HTMLFormElement) => any} onSend - 评论发送完成回调 | |
| * @param {boolean} to_last - 发送完毕更新页面楼层是否更新到最后一页,如果为否则更新到当前页 | |
| */ | |
| function hookSubmit(form, onSend, to_last=true) { | |
| let submit_ongoing = false; | |
| $AEL(form, 'submit', async e => { | |
| if (!GM_getValue('enabled')) { return; } | |
| if (submit_ongoing) { return; } | |
| // 拦截默认行为 | |
| e.preventDefault(); | |
| const ReplyInPage = CONST.Text.Review.ReplyInPage; | |
| // 表单数据 | |
| const formdata = new FormData(form); | |
| // 不允许发送空数据 | |
| if (!formdata.get('pcontent').length) { | |
| Quasar.Notify.create({ | |
| type: 'error', | |
| message: ReplyInPage.NoEmptyContent, | |
| caption: ReplyInPage.NoEmptyContentCaption, | |
| }); | |
| return; | |
| } | |
| // 当评论长度小于7时填充空内容 | |
| if (formdata.get('pcontent').length < 7 && GM_getValue('fillblank')) { | |
| formdata.set('pcontent', formdata.get('pcontent') + '[b][/b]'); | |
| } | |
| // 发送评论 | |
| submit_ongoing = true; | |
| Quasar.Loading.show({ message: ReplyInPage.SendingReply }); | |
| const data = utils.serializeFormData(formdata); | |
| const doc = await utils.requestDocument({ | |
| method: 'POST', | |
| url: form.getAttribute('action'), | |
| data, | |
| headers: { | |
| 'content-type': 'application/x-www-form-urlencoded' | |
| } | |
| }); | |
| Quasar.Loading.hide(); | |
| submit_ongoing = false; | |
| // 发送完成提示 | |
| const is_block = !!$(doc, '.block'); | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: ReplyInPage.ReplySent, | |
| caption: is_block ? $(doc, '.blocktitle').innerText : undefined, | |
| actions: is_block ? [{ | |
| label: ReplyInPage.SentStatusDetails, | |
| async handler() { | |
| // 使用文库返回的block作为详情弹窗内容 | |
| const block = $(doc, '.block').cloneNode(true); | |
| block.classList.add('plus-preserve-border'); | |
| // 移除脚注 | |
| $(block, '.blocknote')?.remove(); | |
| // 点击任意<a>链接时,什么都不做(拦截默认行为与事件处理器) | |
| [...$All(block, 'a')].forEach(a => | |
| $AEL(a, 'click', e => | |
| e.ctrlKey || e.metaKey || e.shiftKey || destroyEvent(e), | |
| { capture: true } | |
| ) | |
| ); | |
| // 点击返回时,关闭弹窗并重新聚焦到编辑器 | |
| [...$All(block, 'a[href="javascript:history.back(1)"]')].forEach(a => | |
| $AEL(a, 'click', e => { | |
| dialog.hide(); | |
| setTimeout(() => $(form, '#pcontent').focus()); | |
| }, { capture: true }) | |
| ); | |
| // 点击关闭此窗口时,关闭弹窗 | |
| [...$All(block, 'a[href="javascript:window.close()"]')].forEach(a => | |
| $AEL(a, 'click', e => { | |
| dialog.hide(); | |
| }, { capture: true }) | |
| ); | |
| // Quasar Dialog 展示详情 | |
| const dialog = Quasar.Dialog.create({ | |
| message: '<div id="plus-reply-detail"></div>', | |
| html: true, | |
| ok: ReplyInPage.DetailsOk, | |
| }); | |
| (await detectDom('#plus-reply-detail')).append(block); | |
| } | |
| }] : [], | |
| group: 'review.replyinpage.reply-sent', | |
| }); | |
| // 回调 | |
| onSend && onSend(form); | |
| // 更新页面楼层 | |
| const page = to_last ? 'last' : new URLSearchParams(location.search).get('page') ?? 1; | |
| await FloorManager.updater.update('loading', page, true, 'push'); | |
| }); | |
| } | |
| return { hookSubmit, }; | |
| }, | |
| }, | |
| editinpage: { | |
| desc: '编辑楼层功能页面内完成', | |
| dependencies: ['FloorManager', 'replyinpage'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.editinpage.func>>} editinpage */ | |
| async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {FloorManager} */ | |
| const FloorManager = pool.require('FloorManager'); | |
| /** @type {replyinpage} */ | |
| const replyinpage = pool.require('replyinpage'); | |
| /** @type {darkmode} */ | |
| const darkmode = await require('darkmode', true); | |
| /** @type {ubbeditor} */ | |
| const ubbeditor = await require('ubbeditor', true); | |
| GM_getValue = utils.defaultedGet({ | |
| enabled: true, | |
| }, GM_getValue); | |
| configs.registerSettings('review', [{ | |
| type: 'boolean', | |
| label: CONST.Text.Review.Settings.EditInPage, | |
| caption: CONST.Text.Review.Settings.EditInPageCaption, | |
| key: 'editinpage', | |
| get() { return GM_getValue('enabled'); }, | |
| set(val) { return GM_setValue('enabled', val); }, | |
| }], GM_addValueChangeListener); | |
| FloorManager.applyToAllFloors(hookEdit); | |
| let editing = false; | |
| /** | |
| * 将楼层的编辑按钮(如果有)点击后改为在页面内编辑,而不是打开一个新页面 | |
| * @param {Floor} floor | |
| */ | |
| function hookEdit(floor) { | |
| const edit = FloorManager.parser.getFloorButton(floor, 'edit'); | |
| const yid = floor.data.yid; | |
| if (!edit) { return; } | |
| $AEL(edit.element, 'click', async e => { | |
| if (!GM_getValue('enabled')) { return; } | |
| // 按下Ctrl/Meta/Shift时,为用户显式指定在新标签页/新窗口打开,不拦截 | |
| if (e.ctrlKey || e.metaKey || e.shiftKey) { return; } | |
| // 阻止打开新页面 | |
| e.preventDefault(); | |
| // 防止重复编辑 | |
| if (editing) { return; } | |
| editing = true; | |
| // 获取编辑框部分html | |
| const url = `/modules/article/reviewedit.php?yid=${yid}&ajax_gets=jieqi_contents`; | |
| const doc = await utils.requestDocument({ | |
| method: 'GET', url, | |
| }); | |
| const editor = $(doc, 'form[name="frmreview"]').cloneNode(true); | |
| [...$All(editor, 'script')].forEach(s => s.remove()); | |
| const editor_html = editor.outerHTML; | |
| // 获取页面资源 | |
| const [editor_js, common_js] = await Promise.all([ | |
| utils.requestText({ | |
| method: 'GET', | |
| url: '/scripts/ubbeditor_gbk.js' | |
| }), | |
| utils.requestText({ | |
| method: 'GET', | |
| url: '/scripts/common.js' | |
| }), | |
| ]); | |
| // 合成整体html | |
| /* | |
| const body_html = [ | |
| // 文档编码 | |
| `<meta charset="${ document.characterSet }">`, | |
| // 文库自带CSS | |
| '<link rel="stylesheet" href="/themes/wenku8/style.css">', | |
| // 深色模式CSS | |
| darkmode.getPageCSS(url).map(css => `<style>${css}</style>`).join('\n'), | |
| // UBBEditor所用loadJS依赖 | |
| '<script src="/scripts/common.js"></script>', | |
| // 编辑器和表单 | |
| editor_html, | |
| ].join('\n'); | |
| */ | |
| const body_html = [ | |
| // 文库自带CSS | |
| '<link rel="stylesheet" href="/themes/wenku8/style.css">', | |
| // 深色模式CSS | |
| darkmode.getPageCSS(url).map(css => `<style>${css}</style>`).join('\n'), | |
| // Material Icons | |
| `<style>${ GM_getResourceText('quasar-icon') || GM_getResourceText('quasar-icon-bak') }</style>`, | |
| // JS依赖 | |
| `<script>${ common_js }</script>`, | |
| // 编辑器和表单 | |
| editor_html, | |
| // UBBEditor | |
| `<script>${ editor_js };\nUBBEditor.Create("pcontent");</script>`, | |
| ].join('\n'); | |
| // 深色模式 | |
| const body_class = darkmode.actual_enabled ? 'plus-darkmode' : ''; | |
| const html = ` | |
| <body | |
| class="${body_class}" | |
| style="overflow: hidden;" | |
| > | |
| ${body_html} | |
| </body> | |
| `; | |
| darkmode.onToggle(enabled => { | |
| iframe.contentDocument?.body.classList[enabled ? 'add' : 'remove']('plus-darkmode') | |
| }); | |
| // 包装到iframe中 | |
| /** @type {HTMLIFrameElement} */ | |
| const iframe = $$CrE({ | |
| tagName: 'iframe', | |
| props: { | |
| srcdoc: html, | |
| }, | |
| styles: { | |
| border: 'none', | |
| }, | |
| listeners: [[ | |
| 'load', e => { | |
| const doc = iframe.contentDocument; | |
| // 调整宽高 | |
| function resize() { | |
| iframe.width = doc.body.scrollWidth; | |
| iframe.height = doc.body.scrollHeight; | |
| } | |
| resize(); | |
| const observer = new ResizeObserver(entries => resize()); | |
| observer.observe(iframe.contentDocument.body); | |
| // 这里无法在onDismiss中unobserve,因为onDismiss时iframe的body已不存在 | |
| //dialog.onDismiss(() => observer.unobserve(iframe.contentDocument.body)); | |
| // 编辑器修复与增强 | |
| const form = $(doc, 'form[name="frmreview"]'); | |
| replyinpage.hookSubmit(form, () => dialog.hide(), false); | |
| ubbeditor.enhance(form); | |
| // 按下Esc时关闭弹窗 | |
| $AEL(doc, 'keyup', e => e.code === 'Escape' && dialog.hide()); | |
| // 但是在编辑框内不要按下直接Esc就关闭,因为有可能是在和输入法交互 | |
| // 记录:如果正在和输入法交互,或者过去250毫秒内和输入法交互过,就忽略此次Escape按键 | |
| let is_composing = false, last_composed = 0; | |
| const pcontent = $(doc, '#pcontent'); | |
| const ptitle = $(doc, '#ptitle'); | |
| [pcontent, ptitle].filter(elm => !!elm).forEach(elm => { | |
| $AEL(elm, 'compositionstart', e => is_composing = true); | |
| $AEL(elm, 'compositionend', e => { | |
| is_composing = false; | |
| last_composed = Date.now(); | |
| }); | |
| $AEL(elm, 'keyup', e => | |
| (is_composing || Date.now() - last_composed < 250) && e.stopPropagation()); | |
| }); | |
| }, | |
| ]] | |
| }); | |
| // 在 Quasar Dialog 中展示 | |
| const dialog = Quasar.Dialog.create({ | |
| message: `<div id="plus-edit-dialog"></div>`, | |
| html: true, | |
| ok: false, | |
| cancel: false, | |
| style: { | |
| width: 'fit-content', | |
| height: 'fit-content', | |
| maxWidth: 'none', | |
| }, | |
| }).onDismiss(() => editing = false); | |
| (await detectDom('#plus-edit-dialog')).append(iframe); | |
| }); | |
| } | |
| }, | |
| }, | |
| autorefresh: { | |
| desc: '自动刷新楼层', | |
| dependencies: ['FloorManager'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {FloorManager} */ | |
| const FloorManager = pool.require('FloorManager'); | |
| GM_getValue = utils.defaultedGet({ | |
| enabled: false, | |
| refresh_last: true, | |
| }, GM_getValue); | |
| const Settings = CONST.Text.Review.Settings; | |
| configs.registerSettings('review', [{ | |
| type: 'boolean', | |
| label: Settings.AutoRefresh, | |
| caption: Settings.AutoRefreshCaption, | |
| key: 'autorefresh', | |
| get() { return GM_getValue('enabled'); }, | |
| set(val) { GM_setValue('enabled', val)}, | |
| }, { | |
| type: 'boolean', | |
| label: Settings.RefreshToLast, | |
| caption: Settings.RefreshToLastCaption, | |
| key: 'refresh_last', | |
| get() { return GM_getValue('refresh_last'); }, | |
| set(val) { GM_setValue('refresh_last', val); }, | |
| }], GM_addValueChangeListener); | |
| setInterval( | |
| () => GM_getValue('enabled') && document.visibilityState === 'visible' && | |
| (GM_getValue('refresh_last') ? FloorManager.updater.update('notify', 'last', true, 'replace') : FloorManager.updater.update('notify', null, true, 'replace')), | |
| CONST.Internal.ReviewAutoRefreshInterval, | |
| ); | |
| }, | |
| }, | |
| beautifier: { | |
| desc: '页面样式修复增强', | |
| detectDom: 'head', | |
| async func() { | |
| // 回复内引用、代码文字最大宽度限制 | |
| addStyle(` | |
| pre { | |
| white-space: pre-wrap; /* 保留格式但允许自动换行 */ | |
| word-break: break-word; /* 即使没有空格也能断句 */ | |
| overflow-wrap: break-word; /* 兼容性增强 */ | |
| } | |
| `); | |
| // 回复内图片最大宽度限制 | |
| detectDom({ | |
| selector: '.divimage > img', | |
| /** @param {HTMLImageElement} img */ | |
| callback(img) { | |
| const tryResize = () => img.naturalWidth ? resize() : setTimeout(() => tryResize(), CONST.Internal.ReviewResizeInterval); | |
| tryResize(); | |
| function resize() { | |
| img.style.width = `min(100%, ${img.naturalWidth}px)`; | |
| } | |
| } | |
| }); | |
| } | |
| }, | |
| downloader: { | |
| desc: '书评下载保存', | |
| dependencies: ['FloorManager'], | |
| disabled: false, | |
| async func() { | |
| /** @type {sidepanel} */ | |
| const sidepanel = await require('sidepanel', true); | |
| /** @type {component} */ | |
| const component = await require('component', true); | |
| /** @type {FloorManager} */ | |
| const FloorManager = pool.require('FloorManager'); | |
| const pool_funcs = { | |
| core: { | |
| desc: '下载器核心,负责下载功能', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.core.func>>} core */ | |
| func() { | |
| const Downloader = CONST.Text.Review.Downloader; | |
| /** | |
| * 将floors[][]页面数据保存为指定的格式呈递给用户 | |
| * @callback ReviewSaver | |
| * @param {Floor[][]} pages | |
| * @param {InstanceType<typeof utils.ProgressManager>} manager | |
| * @returns {Promise<blob> | blob} 已呈递给用户的blob | |
| */ | |
| /** @satisfies {Record<string, ReviewSaver>} */ | |
| const savers = { | |
| async pdf(pages, manager) { | |
| const { jsPDF } = window.jspdf; | |
| const doc = new jsPDF(); | |
| doc.text("Hello world!", 10, 10); | |
| doc.save("a4.pdf"); | |
| }, | |
| async epub(pages, manager) { | |
| const Epub = CONST.Text.Review.Downloader.Progress.Epub; | |
| const rid = parseInt(new URLSearchParams(location.search).get('rid'), 10); | |
| const review_url = `https://${location.host}/modules/article/reviewshow.php?rid=${rid}`; | |
| const title = pages[0][0].data.title; | |
| const epub = new jEpub(); | |
| epub.init({ | |
| i18n: 'en', | |
| title: title, | |
| author: pages[0][0].data.user.name, | |
| publisher: '', | |
| description: Epub.EpubDescription, | |
| tags: [], | |
| }); | |
| epub.date(new Date()); | |
| epub.notes(replaceText( | |
| Epub.Notes, { | |
| '{URL_HREF}': escJsStr(review_url), | |
| '{URL_TEXT}': utils.htmlEncode(review_url), | |
| } | |
| )); | |
| const all_floors = pages.reduce((all_floors, floors) => ((all_floors.push(...floors), all_floors)), []); | |
| manager.steps = 2; | |
| manager.progress(null, 0); | |
| const manager_images = manager.sub(0, { | |
| icon: 'image', | |
| label: Epub.FetchFloors, | |
| caption: replaceText( | |
| Epub.FetchFloorsCaption, | |
| { '{Total}': Epub.Unknown, '{Finished}': Epub.Unknown } | |
| ), | |
| }); | |
| $AEL(manager_images, 'progress', e => { | |
| manager_images.info.caption = replaceText( | |
| Epub.FetchFloorsCaption, | |
| { '{Total}': manager_images.steps, '{Finished}': manager_images.finished } | |
| ); | |
| }); | |
| /** @type {number} */ | |
| let images_count = 0; | |
| /** @type {Promise<string>} */ | |
| const promises = all_floors.map(async floor => { | |
| // 将DOM所有图片(包括表情)下载并指向本地文件 | |
| let html = floor.element.root.innerHTML; | |
| /** @type {HTMLImageElement[]} */ | |
| const images = [...$All(floor.element.content, 'img')]; | |
| const { rid, yid } = floor.data; | |
| await Promise.all(images.map(async (image, i) => { | |
| images_count++; | |
| try { | |
| const url = image.src; | |
| const blob = await utils.requestBlob(url); | |
| const image_id = `${rid}-${yid}-${i}`; | |
| html = html.replace(image.outerHTML, `<%= image[${ escJsStr(image_id) }] %>`); | |
| epub.image(blob, image_id); | |
| } catch(err) { | |
| /** @type {logger} */ | |
| const logger = await require('logger', true); | |
| logger.log('Warn', 'Image fetching failed', err); | |
| } finally { | |
| manager_images.progress(); | |
| } | |
| })); | |
| return html; | |
| }); | |
| manager_images.steps = images_count; | |
| manager_images.progress(null, manager_images.finished); | |
| const content = (await manager.progress(Promise.all(promises))).join('\n'); | |
| epub.add(title, content); | |
| const manager_blob = manager.sub(100, { | |
| icon: 'book', | |
| label: CONST.Text.Review.Downloader.Progress.Epub.GenerateEpub, | |
| }); | |
| const blob = await manager.progress(epub.generate( | |
| 'blob', | |
| metadata => manager_blob.progress(null, Math.round(metadata.percent)) | |
| )); | |
| const url = URL.createObjectURL(blob); | |
| dl_browser(url, `${title}.epub`); | |
| }, | |
| bbcode(pages, manager) { | |
| manager.steps = 1; | |
| manager.progress(null, 0); | |
| const text = pages.map(page => page.map(floor => { | |
| const data = floor.data; | |
| const number = data.number; | |
| const username = data.user.name; | |
| const userid = data.user.id; | |
| const title = data.title; | |
| const content = data.content; | |
| return `#${number} [${title}] ${username}(${userid})\n${content}`; | |
| }).join('\n\n')).join('\n\n'); | |
| const blob = new Blob([text], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const title = pages[0][0].data.title; | |
| const filename = `${title}.txt`; | |
| dl_browser(url, filename); | |
| setTimeout(async () => { | |
| URL.revokeObjectURL(url); | |
| await manager.progress(); | |
| }); | |
| return blob; | |
| }, | |
| txt(pages, manager) { | |
| manager.steps = 1; | |
| manager.progress(null, 0); | |
| const text = pages.map(page => page.map(floor => { | |
| const data = floor.data; | |
| const number = data.number; | |
| const username = data.user.name; | |
| const userid = data.user.id; | |
| const title = data.title; | |
| const content = floor.element.content.innerText; | |
| return `#${number} [${title}] ${username}(${userid})\n${content}`; | |
| }).join('\n\n')).join('\n\n'); | |
| const blob = new Blob([text], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const title = pages[0][0].data.title; | |
| const filename = `${title}.txt`; | |
| dl_browser(url, filename); | |
| setTimeout(async () => { | |
| URL.revokeObjectURL(url); | |
| await manager.progress(); | |
| }); | |
| return blob; | |
| }, | |
| async html(pages, manager) { | |
| const HTML = CONST.Text.Review.Downloader.Progress.HTML; | |
| manager.steps = 2; | |
| manager.progress(null, 0); | |
| // 将所有楼层拼接到一个document中 | |
| const doc = pages[0][0].element.root.closest('html').parentNode.cloneNode(true); | |
| const pagelink_table = $(doc, '#content > .grid + :not(.grid)'); | |
| pages.forEach((floors, i) => | |
| // 第一页的楼层已有,不要重复添加 | |
| i > 0 && floors.forEach(floor => | |
| pagelink_table.before(floor.element.root.cloneNode(true)) | |
| ) | |
| ); | |
| pagelink_table.remove(); | |
| // 对相同资源url应用缓存 | |
| const CacheMap = new Map(); | |
| // 资源获取进度管理 | |
| const manager_assets = manager.sub(0, { | |
| icon: 'image', | |
| label: HTML.FetchAssets, | |
| caption: replaceText( | |
| HTML.FetchAssetsCaption, | |
| { '{Total}': HTML.Unknown, '{Finished}': HTML.Unknown } | |
| ), | |
| }); | |
| $AEL(manager_assets, 'progress', e => { | |
| manager_assets.info.caption = replaceText( | |
| HTML.FetchAssetsCaption, | |
| { '{Total}': manager_assets.steps, '{Finished}': manager_assets.finished } | |
| ); | |
| }); | |
| // 去除所有script,加载所有style | |
| [...$All(doc, 'script')].forEach(script => script.remove()); | |
| const promise_style = Promise.all([...$All(doc, 'link[rel="stylesheet"]')].map(async link => { | |
| if (!link.href) return; | |
| // 加载css | |
| const href = link.href; | |
| let css = await utils.requestText({ | |
| method: 'GET', | |
| url: href, | |
| }); | |
| // 加载css中的内嵌资源 | |
| /** @type {{ from: string, to: string }[]} 统一存储url => data:url替换数据,最后按照顺序统一替换 */ | |
| const promises_assets = [...css.matchAll(/url\(["']?([^"'\)]*)["']?\)/g)].map(async match => { | |
| const src = new URL(match[1], href).href; | |
| CacheMap.has(src) || CacheMap.set(src, await utils.requestBlob(src)); | |
| const blob = CacheMap.get(src); | |
| const data_url = await blobToDataURL(blob); | |
| await manager_assets.progress(); | |
| return { from: match[0], to: `url(${ escJsStr(data_url) })` }; | |
| }); | |
| manager_assets.steps += promises_assets.length; | |
| manager_assets.progress(null, manager_assets.finished); | |
| const replacements = await Promise.all(promises_assets); | |
| replacements.forEach(r => css = css.replace(r.from, r.to)); | |
| // 用style替换link | |
| link.before($$CrE({ | |
| tagName: 'style', | |
| props: { innerHTML: css }, | |
| })); | |
| link.remove(); | |
| })); | |
| // 加载并内嵌所有图片 | |
| const promises_images = [...$All(doc, 'img')].map(async img => { | |
| try { | |
| const src = new URL(img.src, `${ location.protocol }//${ location.host }/modules/article/reviewshow.php`).href; | |
| CacheMap.has(src) || CacheMap.set(src, await utils.requestBlob(src)); | |
| const blob = CacheMap.get(src); | |
| const data_url = await blobToDataURL(blob); | |
| img.src = data_url; | |
| } catch(err) { | |
| /** @type {logger} */ | |
| const logger = await require('logger', true); | |
| logger.log('Warn', 'Image fetching failed', err); | |
| } finally { | |
| await manager_assets.progress(); | |
| } | |
| }); | |
| manager_assets.steps += promises_images.length; | |
| manager_assets.progress(null, manager_assets.finished); | |
| const promise_images = Promise.all(promises_images); | |
| await manager.progress(Promise.all([ | |
| promise_style, | |
| promise_images, | |
| ])); | |
| // 将所有链接改为绝对链接 | |
| [...$All(doc, 'a[href]')].forEach(a => a.href = a.href); | |
| // 更正编码为utf-8 | |
| $(doc, 'meta[http-equiv="Content-Type"]')?.setAttribute('content', 'text/html; charset=utf-8'); | |
| // 合成文件 | |
| const html = new XMLSerializer().serializeToString(doc); | |
| const blob = new Blob([html], { type: 'text/html' }); | |
| const url = URL.createObjectURL(blob); | |
| const title = pages[0][0].data.title; | |
| const filename = `${title}.html`; | |
| dl_browser(url, filename); | |
| setTimeout(async () => { | |
| URL.revokeObjectURL(url); | |
| await manager.progress(); | |
| }); | |
| return blob; | |
| /** | |
| * 将Blob对象转换为data: url | |
| * @param {Blob} blob | |
| * @returns {Promise<string>} | |
| */ | |
| function blobToDataURL(blob) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(reader.result); | |
| reader.onerror = () => reject(reader.error); | |
| reader.readAsDataURL(blob); | |
| }); | |
| } | |
| }, | |
| }; | |
| /** | |
| * 下载书评 | |
| * @param {Object} details | |
| * @param {number} details.rid | |
| * @param {keyof typeof savers} details.format | |
| * @param {number} details.min_page 下载页码范围-起始页码(包含) | |
| * @param {number} details.max_page 下载页码范围-终止页码(包含) | |
| * @param {InstanceType<typeof utils.ProgressManager>} manager | |
| * @returns {Promise<blob> | blob} 已呈递给用户的blob | |
| */ | |
| async function download({ rid, format, min_page, max_page }, manager) { | |
| // 进度管理:嵌套两层进度,大进度分为获取页面楼层和合成文件两步,小进度为步骤内具体进度 | |
| manager.steps = 2; | |
| manager.info = { | |
| icon: 'save_alt', | |
| label: Downloader.Progress.RootLabel, | |
| }; | |
| await manager.progress(null, 0); | |
| // 获取页面楼层的进度管理器 | |
| const fetch_manager = manager.sub(1, { | |
| icon: 'downloading', | |
| label: Downloader.Progress.PagesLabel, | |
| caption: replaceText( | |
| Downloader.Progress.PagesCaption, | |
| { | |
| '{Total}': Downloader.Progress.Unknown, | |
| '{Finished}': Downloader.Progress.Unknown, | |
| }, | |
| ) | |
| }); | |
| $AEL(fetch_manager, 'progress', e => { | |
| fetch_manager.info.caption = replaceText( | |
| Downloader.Progress.PagesCaption, | |
| { | |
| '{Total}': fetch_manager.steps, | |
| '{Finished}': fetch_manager.finished, | |
| }, | |
| ) | |
| }); | |
| // 合成文件的进度管理器 | |
| const generate_manager = manager.sub(1, { | |
| icon: 'archive', | |
| label: Downloader.Progress.BBCode.MakeFile, | |
| }); | |
| // 获取所有页面 | |
| const req = utils.toQueued(utils.requestDocument, { | |
| max: 10, | |
| sleep: 0, | |
| queue_id: 'review.downloader.core.download.requestDocument', | |
| }); | |
| /** @type {Promise<Floor[]>[]} 存储所有页面的结果floors */ | |
| const promises = []; | |
| for (let page = min_page; page <= max_page; page++) { | |
| promises.push(new Promise(async (resolve, reject) => { | |
| try { | |
| const doc = await req({ | |
| method: 'GET', | |
| url: `/modules/article/reviewshow.php?rid=${ rid }&page=${ page }`, | |
| }); | |
| const floors = FloorManager.parser.parseAll(doc); | |
| await fetch_manager.progress(); | |
| resolve(floors); | |
| } catch (err) { | |
| fetch_manager.newError('self'); | |
| reject(err); | |
| } | |
| })); | |
| } | |
| fetch_manager.steps = max_page; | |
| fetch_manager.progress(null, 0); | |
| const pages = await manager.progress(Promise.all(promises)); | |
| // 合成文件并呈递给用户 | |
| const saver = savers[format]; | |
| const blob = await manager.progress(Promise.resolve(saver(pages, generate_manager))); | |
| return blob; | |
| } | |
| return { download }; | |
| } | |
| }, | |
| gui: { | |
| desc: '下载器界面', | |
| dependencies: ['core'], | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.gui.func>>} gui */ | |
| async func() { | |
| /** @type {core} */ | |
| const core = inner_pool.require('core'); | |
| const Downloader = CONST.Text.Review.Downloader; | |
| const container = $CrE('div'); | |
| container.innerHTML = ` | |
| <q-dialog v-model="visible" full-width full-height> | |
| <q-layout view="hHr lpR fFr" container> | |
| <q-header bordered class="bg-primary text-white"> | |
| <q-toolbar> | |
| <q-toolbar-title> | |
| <q-icon name="archive"></q-icon> | |
| ${ Downloader.Title } | |
| </q-toolbar-title> | |
| <q-btn :disable="downloading" icon="close" v-close-popup flat square></q-btn> | |
| </q-toolbar> | |
| </q-header> | |
| <q-drawer show-if-above v-model="has_progress" side="right" bordered> | |
| <p-progress v-if="has_progress" :manager="download_progress"></p-progress> | |
| <div v-else class="absolute-center">${ Downloader.ProgressPlaceholder }</div> | |
| </q-drawer> | |
| <q-page-container> | |
| <q-page> | |
| <q-card square class="review-downloader-container q-pa-md"> | |
| <q-card-section class="text-body1"> | |
| <div class="text-h6">${ Downloader.ReviewInfo }</div> | |
| <q-list> | |
| <q-item><q-item-section>${ Downloader.ReviewTitle }: {{ title }}</q-item-section></q-item> | |
| <q-item><q-item-section>${ Downloader.ReviewPages }: {{ max_page }}</q-item-section></q-item> | |
| <q-item><q-item-section>${ Downloader.ReviewID }: {{ rid }}</q-item-section></q-item> | |
| </q-list> | |
| </q-card-section> | |
| <q-separator></q-separator> | |
| <q-card-section> | |
| <div class="text-h6">${ Downloader.DownloadOptions }</div> | |
| <q-list class="text-body1"> | |
| <!-- 下载格式 --> | |
| <q-item tag="label"> | |
| <q-item-section avatar> | |
| <q-avatar icon="picture_as_pdf"></q-avatar> | |
| </q-item-section> | |
| <q-item-section> | |
| <span>${ Downloader.Format }</span> | |
| </q-item-section> | |
| <q-item-section> | |
| <q-select | |
| emit-value | |
| map-options | |
| :options="options" | |
| v-model="format" | |
| ></q-select> | |
| </q-item-section> | |
| </q-item> | |
| </q-list> | |
| </q-card-section> | |
| </q-card> | |
| </q-page> | |
| </q-page-container> | |
| <q-footer bordered class="bg-grey-8 text-white"> | |
| <q-toolbar> | |
| <q-toolbar-title> | |
| </q-toolbar-title> | |
| <q-btn | |
| label="${ Downloader.Cancel }" | |
| color="secondary" | |
| :disable="downloading" | |
| v-close-popup | |
| flat | |
| ></q-btn> | |
| <q-btn | |
| label="${ Downloader.Download }" | |
| @click="download" | |
| color="primary" | |
| :loading="downloading" | |
| flat | |
| ></q-btn> | |
| </q-toolbar> | |
| </q-footer> | |
| </q-layout> | |
| </q-dialog> | |
| `; | |
| document.body.append(container); | |
| // 下载界面样式 | |
| addStyle(` | |
| .review-downloader-container { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| `); | |
| let instance; | |
| const app = Vue.createApp({ | |
| data() { | |
| return { | |
| // 主UI | |
| options: structuredClone(Downloader.Formats), | |
| visible: false, | |
| inited: false, | |
| rid: 0, | |
| title: '', | |
| max_page: 0, | |
| format: 'bbcode', | |
| download_progress: null, | |
| downloading: false, | |
| }; | |
| }, | |
| methods: { | |
| /** | |
| * 根据页面上的配置开始下载 | |
| * @returns {Promise<boolean>} 是否下载成功 | |
| */ | |
| async download() { | |
| if (!this.inited) return; | |
| if (this.downloading) return; | |
| this.downloading = true; | |
| const manager = this.download_progress = new utils.ProgressManager(); | |
| await core.download({ | |
| rid: this.rid, | |
| format: this.format, | |
| min_page: 1, | |
| max_page: this.max_page, | |
| }, manager); | |
| this.downloading = false; | |
| /* | |
| component.dialog('p-progress-dialog', { | |
| seamless: true, position: 'bottom', value: manager | |
| }); | |
| */ | |
| }, | |
| /** | |
| * 以给定参数初始化下载器 | |
| * @param {number} rid - 书评id | |
| * @returns {Promise} | |
| */ | |
| async init(rid) { | |
| this.inited = false; | |
| this.rid = rid; | |
| const doc = await utils.requestDocument({ | |
| method: 'GET', | |
| url: `/modules/article/reviewshow.php?rid=${ rid }&page=1`, | |
| }); | |
| const floors = FloorManager.parser.parseAll(doc); | |
| this.title = floors[0].data.title; | |
| this.max_page = parseInt(new URLSearchParams($(doc, '#pagelink > .last').search).get('page'), 10); | |
| this.inited = true; | |
| } | |
| }, | |
| computed: { | |
| has_progress() { | |
| return !!this.download_progress; | |
| } | |
| }, | |
| mounted() { | |
| instance = this; | |
| } | |
| }); | |
| component.register(app, 'p-progress'); | |
| app.use(Quasar); | |
| app.mount(container); | |
| function show() { | |
| instance.visible = true; | |
| } | |
| function hide() { | |
| instance.visible = false; | |
| } | |
| /** | |
| * 以给定参数初始化下载器 | |
| * @param {number} rid - 书评id | |
| * @returns {Promise} | |
| */ | |
| function init() { | |
| return instance.init.apply(instance, arguments); | |
| } | |
| return { show, hide, init }; | |
| } | |
| }, | |
| main: { | |
| desc: '主逻辑入口', | |
| dependencies: ['core', 'gui'], | |
| async func() { | |
| /** @type {gui} */ | |
| const gui = inner_pool.require('gui'); | |
| const Downloader = CONST.Text.Review.Downloader; | |
| sidepanel.registerButton({ | |
| id: 'review.downloader.download', | |
| icon: 'archive', | |
| label: Downloader.SideButton, | |
| index: 3, | |
| callback() { | |
| const rid = parseInt(new URLSearchParams(location.search).get('rid'), 10); | |
| gui.init(rid); | |
| gui.show(); | |
| } | |
| }); | |
| }, | |
| }, | |
| }; | |
| const { pool: inner_pool, promise } = utils.loadFuncInNewPool(pool_funcs, { | |
| GM_getValue, GM_setValue, GM_addValueChangeListener | |
| }); | |
| await promise; | |
| } | |
| }, | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); | |
| await promise; | |
| return { | |
| /** @type {FloorManager} */ | |
| FloorManager: pool.require('FloorManager'), | |
| /** @type {citing} */ | |
| citing: pool.require('citing'), | |
| messager, | |
| _types: { | |
| /** @type {Floor} */ | |
| Floor: {}, | |
| } | |
| } | |
| } | |
| }, | |
| bbcode: { | |
| desc: '适用于文库的BBCode解析器', | |
| dependencies: ['logger', 'utils'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.bbcode.func>>} bbcode */ | |
| async func() { | |
| /** @type {logger} */ | |
| const logger = require('logger'); | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| // 解析器 | |
| const parser = new BBCodeParser.BBCodeParser(); | |
| // 定义文库格式tags | |
| /** @typedef {(params: string | null, content: string | null) => string} TagFunction */ | |
| /** @typedef {{ openTag: TagFunction, [closeTag]: TagFunction }} TagDefination */ | |
| /** | |
| * @param {string} tagName | |
| * @returns {TagDefination} | |
| */ | |
| const simpleTag = tagName => ({ | |
| openTag(params, content) { | |
| return `<${ tagName }>`; | |
| }, | |
| closeTag(params, content) { | |
| return `</${ tagName }>`; | |
| } | |
| }); | |
| /** @type {Record<string, TagDefination>} */ | |
| const ADD_TAGS = { | |
| 'size': { | |
| // 文库的size不支持非整数字体大小值 | |
| openTag(params, content) { | |
| const size = params; | |
| if (!size.includes('.')) { | |
| return `<span style="font-size: ${ size }px;">`; | |
| } else { | |
| return `[size=${ size }]`; | |
| } | |
| }, | |
| closeTag(params, content) { | |
| const size = params; | |
| if (!size.includes('.')) { | |
| return '</span>'; | |
| } else { | |
| return '[/size]'; | |
| } | |
| }, | |
| }, | |
| 'b': simpleTag('b'), | |
| 'i': simpleTag('i'), | |
| 'u': simpleTag('u'), | |
| 'd': simpleTag('del'), | |
| 'color': { | |
| openTag(params, content) { | |
| return `<span style="color: #${ params };">`; | |
| }, | |
| closeTag(params, content) { | |
| return '</span>' | |
| }, | |
| }, | |
| 'code': { | |
| openTag(params, content) { | |
| return '<div class="jieqiCode"><code><pre>'; | |
| }, | |
| closeTag(params, content) { | |
| return '</pre></code></div>' | |
| }, | |
| }, | |
| 'quote': { | |
| openTag(params, content) { | |
| return 'Quote:<div class="jieqiQuote">'; | |
| }, | |
| closeTag(params, content) { | |
| return '</div>' | |
| }, | |
| }, | |
| 'url': { | |
| openTag(params, content) { | |
| let url = params ?? content; | |
| url = url.startsWith('http://') || url.startsWith('https://') ? url : 'http://' + url; | |
| return `<a href="${ url }" target="_blank">`; | |
| }, | |
| closeTag(params, content) { | |
| return '</a>' | |
| }, | |
| }, | |
| 'email': { | |
| openTag(params, content) { | |
| return `<a href="mailto:${ content }">`; | |
| }, | |
| closeTag(params, content) { | |
| return '</a>' | |
| }, | |
| }, | |
| 'align': { | |
| openTag(params, content) { | |
| return `<p align="${ params }">`; | |
| }, | |
| closeTag(params, content) { | |
| return '</p>'; | |
| } | |
| }, | |
| }; | |
| parser.register(ADD_TAGS); | |
| /** | |
| * 转换文库格式书评源码为html | |
| * @param {string} bbcode | |
| * @returns {string} | |
| */ | |
| function bbcode2html(bbcode) { | |
| // 解析bbcode | |
| const result = parser.parse(bbcode); | |
| let html = result.html; | |
| // 如果解析错误,说明是用户bbcode格式问题,简单log一下错误 | |
| result.errors.length && logger.log('Warn', result.errors); | |
| // 解析bbcode以外的文库格式 | |
| // 图片 | |
| const img_matches = html.matchAll(/(^|\s)(https?:\/\/\S*\.(?:jpg|jpeg|png|gif))/g); | |
| for (const match of img_matches) { | |
| html = html.replace(match[0], `${match[1]}<div class="divimage"><img src=${ escJsStr(match[2]) } border="0"></div>`); | |
| } | |
| // 表情 | |
| for (const emoji of CONST.Internal.WenkuEmojis) { | |
| const symbol = emoji[0]; | |
| const src = `/images/smiles/${ emoji[1] }`; | |
| const alt = emoji[2]; | |
| const code = `<img src="${ src }" alt="${ alt }" border="0">`; | |
| html = html.replaceAll(symbol, code); | |
| } | |
| // 换行 | |
| html = html.replaceAll('\n', '<br>'); | |
| return html; | |
| } | |
| /** | |
| * 转换文库文库格式富文本html为bbcode | |
| * @param {HTMLDivElement | string} html_or_container - 包含html结构的元素或源代码形式的html | |
| */ | |
| function html2bbcode(html_or_container) { | |
| // 思路:为每种类型的文库富文本项目定义一个转换器,提供test方法用于判断给定DOM Node是否为该类型; | |
| // 如果是,从该Node开始连续多少Node都是同一个富文本项;再将这些Nodes传入转换器的convert方法转换为bbcode | |
| // 转换过程:将所有Node依次传入 | |
| /** | |
| * @typedef {Object} Converter 代表一种bbcode节点的 节点 => bbcode代码 的转换器 | |
| * @property {(node: Node) => number | boolean} test - 返回 从给定节点开始,有多少节点为当前节点类型,如不是当前节点类型则为0;true/false分别可代替1/0 | |
| * @property {(node: Node | Node[]) => string} convert - 转换给定节点为bbcode的方法,需先调用test确定节点数量再调用;当数量为1时,直接传入节点,否则传入节点数组 | |
| */ | |
| /** | |
| * 创建仅根据html标签名和bbcode标签名即可进行转换的简单转换器 | |
| * @param {string} html_tag | |
| * @param {string} bbcode_tag | |
| * @returns {Converter} | |
| */ | |
| const simpleConverter = (html_tag, bbcode_tag) => ({ | |
| test(node) { | |
| return node.nodeName?.toUpperCase() === html_tag.toUpperCase(); | |
| }, | |
| /** @param {Node} node */ | |
| convert(node) { | |
| const t = bbcode_tag.toLowerCase(); | |
| return `[${ t }]${ html2bbcode(node) }[/${ t }]`; | |
| } | |
| }); | |
| const converters = { | |
| // bbcode部分 | |
| size: { | |
| /** @param {HTMLSpanElement | Node} node */ | |
| test(node) { | |
| return node.matches?.('span[style]') && /font-size: \d+px;/.test(node.getAttribute('style')); | |
| }, | |
| /** @param {HTMLSpanElement} node */ | |
| convert(node) { | |
| const size = node.style.fontSize.match(/(\d+)px/)[1]; | |
| const inner_code = html2bbcode(node); | |
| return `[size=${ size }]${ inner_code }[/size]`; | |
| } | |
| }, | |
| b: simpleConverter('b', 'b'), | |
| i: simpleConverter('i', 'i'), | |
| u: simpleConverter('u', 'u'), | |
| d: simpleConverter('del', 'd'), | |
| color: { | |
| /** @param {HTMLSpanElement | Node} node */ | |
| test(node) { | |
| return node.matches?.('span[style]') && !!node.style.getPropertyValue('color'); | |
| }, | |
| /** @param {HTMLSpanElement} node */ | |
| convert(node) { | |
| // 文库格式color没有#号 | |
| const color = node.getAttribute('style').match(/color: #([^;]*);/)[1]; | |
| const inner_code = html2bbcode(node); | |
| return `[color=${ color }]${ inner_code }[/color]`; | |
| } | |
| }, | |
| code: { | |
| /** @param {HTMLDivElement | Node} node */ | |
| test(node) { | |
| return node.classList?.contains('jieqiCode') && | |
| node.firstElementChild.nodeName === 'CODE' && | |
| node.firstElementChild.firstElementChild.nodeName === 'PRE'; | |
| }, | |
| /** @param {HTMLDivElement} node */ | |
| convert(node) { | |
| const pre = node.firstElementChild.firstElementChild; | |
| return `[code]${ html2bbcode(pre) }[/code]`; | |
| } | |
| }, | |
| quote: { | |
| // Quote项目由一个#text节点接一个<div class="jieqiQuote">组成 | |
| // 需要注意的是,#text节点可能含有quote之前的纯文本内容,而非仅有"Quote:"这个标记 | |
| // 也有可能不含有#text节点,当转换用户选中的部分时,可能没有选中到#text节点而仅选中了后面的div, | |
| // 此时也应判定为quote节点 | |
| /** @param {Text | HTMLDivElement | Node} node */ | |
| test(node) { | |
| // Quote:<div>...</div>,共两个Node | |
| if (node.nodeName === '#text' && node.nodeValue?.endsWith?.('Quote:') && | |
| node.nextElementSibling?.classList.contains('jieqiQuote')) { | |
| return 2; | |
| } | |
| if (node.nodeName === 'DIV' && node.classList.contains('jieqiQuote')) { | |
| return 1; | |
| } | |
| return false; | |
| }, | |
| /** @param {Node | Node[]} node */ | |
| convert(node) { | |
| if (Array.isArray(node)) { | |
| // #text 和 <div class="jieqiQuote"> 都有 | |
| /** @type {HTMLDivElement} */ | |
| const container = node[1]; | |
| const text_part = node[0].nodeValue.substring(0, node[0].nodeValue.length - 'Quote:'.length); | |
| return `${text_part}[quote]${ html2bbcode(container) }[/quote]`; | |
| } else { | |
| // 仅有 <div class="jieqiQuote"> | |
| return `[quote]${ html2bbcode(node) }[/quote]`; | |
| } | |
| } | |
| }, | |
| url: { | |
| /** @param {HTMLAnchorElement | Node} node */ | |
| test(node) { | |
| return node.nodeName === 'A' && | |
| node.target === '_blank' && | |
| node.href.startsWith('http'); | |
| }, | |
| /** @param {HTMLAnchorElement} node */ | |
| convert(node) { | |
| const url = node.getAttribute('href'); | |
| const inner_code = html2bbcode(node); | |
| return `[url=${ url }]${ inner_code }[/url]`; | |
| } | |
| }, | |
| email: { | |
| /** @param {HTMLAnchorElement | Node} node */ | |
| test(node) { | |
| return node.nodeName === 'A' && | |
| node.href.startsWith('mailto:'); | |
| }, | |
| /** @param {HTMLAnchorElement} node */ | |
| convert(node) { | |
| const email = node.innerText; | |
| return `[email]${ email }[/email]`; | |
| } | |
| }, | |
| align: { | |
| /** @param {HTMLParagraphElement | Node} node */ | |
| test(node) { | |
| return node.nodeName === 'P' && | |
| node.hasAttribute('align'); | |
| }, | |
| /** @param {HTMLAnchorElement} node */ | |
| convert(node) { | |
| const align = node.getAttribute('align'); | |
| const inner_code = html2bbcode(node); | |
| return `[align=${ align }]${ inner_code }[/align]`; | |
| } | |
| }, | |
| // 非bbcode部分 | |
| wenku_image: { | |
| /** @param {HTMLDivElement | Node} node */ | |
| test(node) { | |
| return node.nodeName === 'DIV' && | |
| node.classList.contains('divimage') && | |
| node.firstElementChild.nodeName === 'IMG'; | |
| }, | |
| /** @param {HTMLDivElement | Node} node */ | |
| convert(node) { | |
| // 文库格式图片:直接放图片链接 | |
| const url = node.firstElementChild.getAttribute('src'); | |
| // 无需左右两侧添加空格,因为被文库识别并渲染为图片,一定自带了空格 | |
| return url; | |
| } | |
| }, | |
| wenku_emoji: { | |
| /** @param {HTMLImageElement | Node} node */ | |
| test(node) { | |
| return node.nodeName === 'IMG' && | |
| node.getAttribute('src').startsWith('/images/smiles/'); | |
| }, | |
| /** @param {HTMLImageElement} node */ | |
| convert(node) { | |
| // 根据表情包src在对照表中查询表情包对应代码 | |
| const basename = node.src.substring(node.src.lastIndexOf('/') + 1); | |
| const emoji = CONST.Internal.WenkuEmojis.find(emoji => emoji[1] === basename); | |
| Assert(emoji, `html2bbcode.emoji: unrecongnized emoji with basename ${ escJsStr(basename) }`, TypeError); | |
| const code = emoji[0]; | |
| // 文库解析表情代码的方法:直接简单粗暴替换表情包代码为表情包<img>标签 | |
| return code; | |
| } | |
| }, | |
| br: { | |
| /** @param {HTMLBRElement | Node} node */ | |
| test(node) { | |
| return node.nodeName === 'BR'; | |
| }, | |
| /** @param {HTMLBRElement} node */ | |
| convert(node) { | |
| // 文库会将bbcode中的换行符"\n"渲染为"<br>\n", | |
| // 因此从html转换回来时,如果<br>后有\n,就丢弃掉<br> | |
| // 仅当只有<br>后面无\n时,才将<br>转换为\n | |
| const following_newline = | |
| node.nextSibling.nodeName === '#text' && | |
| node.nextSibling.nodeValue.startsWith('\n'); | |
| return following_newline ? '' : '\n'; | |
| } | |
| }, | |
| plain_text: { | |
| /** @param {Text | Node} node */ | |
| test(node) { | |
| // 纯文本节点,且非quote节点 | |
| return node.nodeName === '#text' && !converters.quote.test(node); | |
| }, | |
| /** @param {Text} node */ | |
| convert(node) { | |
| return node.nodeValue; | |
| } | |
| }, | |
| }; | |
| // 将参数转化为container形式 | |
| const container = typeof html_or_container === 'string' ? | |
| utils.html2elm(`<div>${html_or_container}</div>`) : html_or_container; | |
| const nodes = [...container.childNodes]; | |
| // 对container内的每一个节点,进行转换,得到bbcode数组 | |
| const node_bbcodes = []; | |
| for (let i = 0; i < nodes.length; i++) { | |
| const node = nodes[i]; | |
| /** @type {Converter} */ | |
| const converter = Object.values(converters).find(converter => converter.test(node)); | |
| if (converter) { | |
| // 正常情况:找到了该节点对应的转换器 | |
| const nodes_count = +converter.test(node); | |
| const related_nodes = nodes.slice(i, i + nodes_count); | |
| const bbcode = nodes_count === 1 ? | |
| converter.convert(node) : | |
| converter.convert(related_nodes); | |
| node_bbcodes.push(bbcode); | |
| // 至少使用了一个节点,使用更多节点时,跳过对这些节点的遍历 | |
| i += nodes_count - 1; | |
| } else { | |
| // 异常情况:没有该节点对应的转换器 | |
| // 简单提取为innerText,并log错误 | |
| const code = node.innerText ?? node.nodeValue; | |
| node_bbcodes.push(code); | |
| logger.log('Error', `bbcode.html2bbcode: converter not found`, node); | |
| } | |
| }; | |
| // 拼接为总体bbcode返回 | |
| const bbcode = node_bbcodes.join(''); | |
| return bbcode; | |
| } | |
| return { bbcode2html, html2bbcode }; | |
| } | |
| }, | |
| ubbeditor: { | |
| desc: '编辑器修复与增强', | |
| dependencies: ['utils', 'bbcode', 'logger', 'storageupdater', 'configs'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_listValues', 'GM_deleteValue', 'GM_addValueChangeListener'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.ubbeditor.func>>} ubbeditor */ | |
| async func(GM_setValue, GM_getValue, GM_listValues, GM_deleteValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {bbcode} */ | |
| const bbcode = require('bbcode'); | |
| /** @type {logger} */ | |
| const logger = require('logger'); | |
| /** @type {storageupdater} */ | |
| const storageupdater = require('storageupdater'); | |
| /** @type {configs} */ | |
| const configs = require('configs'); | |
| /** | |
| * 草稿条目 | |
| * @typedef {Object} Draft | |
| * @property {string} content 草稿内容 | |
| * @property {string} title 草稿标题 | |
| * @property {string} id 草稿id,自动分配、全局唯一 | |
| * @property {number} created 创建时间 | |
| * @property {number} last_edit 最后编辑时间 | |
| * @property {DraftPage[]} pages 使用过该草稿的全部页面列表 | |
| */ | |
| /** | |
| * 草稿所在页面信息 | |
| * @typedef {Object} DraftPage | |
| * @property {string} url 页面url | |
| * @property {string} title 页面内容标题,注意这不是简单的标签页的标题,而是从页面上提取的人类可读的、有意义的标题 | |
| */ | |
| GM_getValue = utils.defaultedGet({ | |
| /** @type {Draft[]} */ | |
| drafts: [], | |
| /** @type {number} */ | |
| max_drafts: 30, | |
| 'config_version': 1, | |
| }, GM_getValue); | |
| storageupdater.update([ | |
| function v0_v1(config) { | |
| // 升级草稿存储: | |
| // - 旧版草稿条目 { content: string, title: string, id: string } | |
| // - 新版草稿条目 { content: string, title: string, id: string, last_edit: number, page: { url: string, title: string } } | |
| if (Object.hasOwn(config, 'drafts') && config.drafts.length) { | |
| /** @type {{ content: string, title: string, id: string }} */ | |
| const drafts = config.drafts; | |
| const default_time = Date.now(); | |
| drafts.forEach(draft => { | |
| /** @type {Draft} 为了更改数据类型新赋值一个辅助变量,实际还是原来那个对象 */ | |
| const new_draft = draft; | |
| new_draft.created = default_time; | |
| new_draft.last_edit = default_time; | |
| new_draft.pages = []; | |
| }); | |
| } | |
| return config; | |
| } | |
| ], { GM_getValue, GM_setValue, GM_listValues, GM_deleteValue }); | |
| const Settings = CONST.Text.UBBEditor.Settings; | |
| configs.registerConfig('ubbeditor', { | |
| label: Settings.Label, | |
| items: [{ | |
| type: 'number', | |
| label: Settings.MaxDrafts, | |
| caption: Settings.MaxDraftsCaption, | |
| key: 'max_drafts', | |
| get() { return GM_getValue('max_drafts'); }, | |
| set(val) { return GM_setValue('max_drafts', val); }, | |
| }], | |
| GM_addValueChangeListener, | |
| }); | |
| const pool_funcs = { | |
| editor: { | |
| desc: '自行实现的bbcode编辑器', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.editor.func>>} editor */ | |
| async func() { | |
| // 编辑框按钮 | |
| /** | |
| * @callback EditorButtonCallback 按钮点击回调 | |
| * @param {PointerEvent} e - 点击事件 | |
| * @this {EditorButton} - 对按钮自身的引用 | |
| * @returns {Promise<string | void | null>} 返回值:若为字符串,则回调结束后会将此字符串复制给instance.value;若为undefined或null,则什么都不做 | |
| */ | |
| /** | |
| * 自行实现组件的按钮,组件应满足以下定义: | |
| * - 包含且仅包含一个QBtn作为外观UI,并在内部将编辑逻辑绑定了该QBtn的click回调 | |
| * - 每当改变编辑框内容后,都应触发一个update事件,以供外部UI跟踪 | |
| * - 允许实现任意自定义事件并通过listeners属性进行监听 | |
| * @typedef {Object} ComponentButton | |
| * @property {(app: Object) => (string | Promise<string>)} component - 为传入的Vue app注册一个的按钮组件的方法,返回该组件的名称(如p-dialog-fontsize);该组件外观应为一个QBtn风格按钮,自带点击回调实现用户调用该编辑器按钮应有功能 | |
| * @property {Record<string, Function>} [listeners={}] - 需要添加到该组件的事件监听器 | |
| * @property {Record<string, string>} [props={}] - 需要传入该组件的属性,键为传入属性的名称,值为传入属性值对应在编辑器实例中的名称 | |
| */ | |
| /** | |
| * 简单按钮,通过定义图标、文本、回调直接创建按钮 | |
| * @typedef {Object} SimpleButton | |
| * @property {string} icon - 按钮的图标 | |
| * @property {string} title - 按钮的鼠标悬浮提示文本 | |
| * @property {() => Promise<string | null>} [prompt] - 用户输入bbcode内部值的方法,如在Link类型按钮内,此方法即为弹窗让用户输入链接的方法 | |
| * @property {EditorButtonCallback} callback - 按钮点击回调 | |
| */ | |
| /** | |
| * @typedef {Object} CommonButtonProps | |
| * @property {boolean} [disabled=false] - 是否停用此按钮,适合开发时使用 | |
| */ | |
| /** @typedef {(ComponentButton | SimpleButton) & CommonButtonProps} EditorButton */ | |
| const Editor = CONST.Text.UBBEditor.Editor; | |
| const Buttons = Editor.Buttons; | |
| const Components = Editor.Components; | |
| /** @type {EditorButton[]} */ | |
| const buttons = [{ | |
| component(app) { | |
| app.component('PDialogFontsize', { | |
| name: 'PDialogFontsize', | |
| props: [], | |
| emits: ['update'], | |
| template: ` | |
| <!-- 按钮 --> | |
| <q-btn | |
| icon="format_size" | |
| @click="show" | |
| flat | |
| v-bind="$attrs" | |
| > | |
| <q-tooltip | |
| anchor="top middle" | |
| self="bottom middle" | |
| >${ Buttons.Size }</q-tooltip> | |
| </q-btn> | |
| <!-- 弹窗 --> | |
| <q-dialog v-model="visible"> | |
| <q-card> | |
| <q-card-section> | |
| <div :style="{ minWith: '20vw', maxWidth: '40vw', fontSize: size + 'px' }"> | |
| ${ Components.PDialogFontsize.PreviewText } | |
| </div> | |
| </q-card-section> | |
| <q-card-section> | |
| <q-input | |
| type="number" | |
| v-model.number="size" | |
| :rules="[ size => size > 0 || ${ escJsStr(Components.PDialogFontsize.NoNegativeFontSize, "'") } ]" | |
| debounce="500" | |
| ref="input" | |
| autofocus | |
| @keydown="onkeydown" | |
| ></q-input> | |
| </q-card-section> | |
| <q-card-actions align="right"> | |
| <q-btn v-close-popup flat>${ Components.PDialogFontsize.Cancel }</q-btn> | |
| <q-btn @click="submit" flat>${ Components.PDialogFontsize.Ok }</q-btn> | |
| </q-card-actions> | |
| </q-card> | |
| </q-dialog> | |
| `, | |
| data() { | |
| return { | |
| visible: false, | |
| size: 12, | |
| } | |
| }, | |
| methods: { | |
| /** | |
| * 显示Dialog | |
| */ | |
| show() { | |
| this.visible = true; | |
| }, | |
| /** | |
| * 输入框的用户键盘按下事件处理器 | |
| * @param {KeyboardEvent} e | |
| */ | |
| onkeydown(e) { | |
| if (e.code === 'Enter') { | |
| e.stopPropagation(); | |
| this.submit(); | |
| } | |
| }, | |
| /** 提交值到编辑器 */ | |
| submit() { | |
| this.visible = false; | |
| const str_size = this.size.toString(); | |
| applyBBCodeTag('size', str_size, true); | |
| this.$emit('update'); | |
| }, | |
| }, | |
| }); | |
| return 'p-dialog-fontsize'; | |
| } | |
| }, { | |
| icon: 'format_bold', | |
| title: Buttons.Bold, | |
| async callback(e) { | |
| return applyBBCodeTag('b', null, true); | |
| } | |
| }, { | |
| icon: 'format_italic', | |
| title: Buttons.Italic, | |
| async callback(e) { | |
| return applyBBCodeTag('i', null, true); | |
| } | |
| }, { | |
| icon: 'format_underlined', | |
| title: Buttons.Underline, | |
| async callback(e) { | |
| return applyBBCodeTag('u', null, true); | |
| } | |
| }, { | |
| icon: 'format_strikethrough', | |
| title: Buttons.Del, | |
| async callback(e) { | |
| return applyBBCodeTag('d', null, true); | |
| } | |
| }, { | |
| async component(app) { | |
| app.component('PDialogFontcolor', { | |
| name: 'PDialogFontcolor', | |
| props: [], | |
| emits: ['update'], | |
| template: ` | |
| <!-- 按钮 --> | |
| <q-btn | |
| icon="format_color_text" | |
| @click="show" | |
| flat | |
| v-bind="$attrs" | |
| > | |
| <q-tooltip | |
| anchor="top middle" | |
| self="bottom middle" | |
| >${ Buttons.Color }</q-tooltip> | |
| </q-btn> | |
| <!-- 弹窗 --> | |
| <q-dialog v-model="visible"> | |
| <q-card> | |
| <q-card-section> | |
| <div | |
| :style="{ minWith: '20vw', maxWidth: '40vw', fontSize: size + 'px' }" | |
| v-html="preview_html" | |
| ></div> | |
| </q-card-section> | |
| <q-card-section> | |
| <q-color | |
| v-model="color" | |
| default-view="palette" | |
| format-model="hex" | |
| ></q-color> | |
| </q-card-section> | |
| <q-card-section> | |
| <q-item tag="label"> | |
| <q-item-section> | |
| ${ Components.PDialogFontcolor.Darkmode } | |
| </q-item-section> | |
| <q-item-section avatar> | |
| <q-toggle | |
| v-model="dark" | |
| color="primary" | |
| ></q-toggle> | |
| </q-item-section> | |
| </q-item> | |
| </q-card-section> | |
| <q-card-actions align="right"> | |
| <q-btn v-close-popup flat>${ Components.PDialogFontcolor.Cancel }</q-btn> | |
| <q-btn @click="submit" flat>${ Components.PDialogFontcolor.Ok }</q-btn> | |
| </q-card-actions> | |
| </q-card> | |
| </q-dialog> | |
| `, | |
| data() { | |
| /** @type {darkmode} */ | |
| const darkmode = require('darkmode'); | |
| const dark = !!darkmode?.actual_enabled; | |
| return { | |
| /** 弹窗可见性 */ | |
| visible: false, | |
| /** 是否将页面置为深色模式以供预览 */ | |
| dark: dark, | |
| /** 含"#"前缀的颜色值,QColor的v-model绑定 */ | |
| color: '', | |
| }; | |
| }, | |
| methods: { | |
| /** 展示弹窗 */ | |
| show() { | |
| this.visible = true; | |
| }, | |
| /** 恢复页面原本应有的深色模式状态 */ | |
| async resetDarkmode() { | |
| /** @type {darkmode} */ | |
| const darkmode = await require('darkmode', true); | |
| darkmode.setActualDark(darkmode.actual_enabled); | |
| }, | |
| /** submit事件,为dialog函数准备的提交接口,会关闭dialog弹窗并提交值 */ | |
| submit() { | |
| applyBBCodeTag('color', this.color.substring(1), true); | |
| this.visible = false; | |
| this.$emit('update'); | |
| }, | |
| }, | |
| computed: { | |
| /** 用于预览的文字HTML */ | |
| preview_html() { | |
| return replaceText( | |
| Components.PDialogFontcolor.PreviewHTML, | |
| { '{HEX}': this.color } | |
| ) | |
| }, | |
| }, | |
| watch: { | |
| async dark(new_val, old_val) { | |
| /** @type {darkmode} */ | |
| const darkmode = await require('darkmode', true); | |
| new_val !== old_val && (darkmode.setActualDark(new_val)); | |
| }, | |
| async visible(new_val, old_val) { | |
| // 当弹窗隐藏时时,使页面的深色模式实际开启状态和用户的深色模式设置同步 | |
| await this.resetDarkmode(); | |
| } | |
| }, | |
| async mounted() { | |
| const that = this; | |
| // 当页面的深色模式设置改变时,同步到组件 | |
| /** @type {darkmode} */ | |
| const darkmode = await require('darkmode', true); | |
| darkmode.onToggle(enabled => { | |
| if (that.dark !== enabled) { | |
| that.dark = enabled; | |
| } | |
| }); | |
| }, | |
| async unmounted() { | |
| // 当组件卸载时,使页面的深色模式实际开启状态和用户的深色模式设置同步 | |
| await this.resetDarkmode(); | |
| } | |
| }); | |
| return 'p-dialog-fontcolor'; | |
| }, | |
| }, { | |
| icon: 'code', | |
| title: Buttons.Code, | |
| async callback(e) { | |
| return applyBBCodeTag('code', null, true); | |
| } | |
| }, { | |
| icon: 'format_quote', | |
| title: Buttons.Quote, | |
| async callback(e) { | |
| return applyBBCodeTag('quote', null, true); | |
| } | |
| }, { | |
| icon: 'add_link', | |
| title: Buttons.Link, | |
| async prompt() { | |
| const { promise, reject, resolve } = Promise.withResolvers(); | |
| Quasar.Dialog.create({ | |
| title: '插入链接', | |
| message: '插入链接:', | |
| prompt: { | |
| model: '', | |
| type: 'text', | |
| isValid: val => val.startsWith('http://') || val.startsWith('https://'), | |
| }, | |
| ok: { | |
| label: '确认', | |
| color: 'primary', | |
| }, | |
| cancel: { | |
| label: '取消', | |
| color: 'secondary', | |
| }, | |
| }).onOk(resolve).onCancel(() => resolve(null)); | |
| return promise; | |
| }, | |
| async callback(e) { | |
| const url = await this.prompt(); | |
| if (!url) return null; | |
| return applyBBCodeTag('url', url, true); | |
| } | |
| }, { | |
| icon: 'attach_email', | |
| title: Buttons.Email, | |
| async prompt() { | |
| const { promise, reject, resolve } = Promise.withResolvers(); | |
| Quasar.Dialog.create({ | |
| title: '插入Email', | |
| message: '插入Email:', | |
| prompt: { | |
| model: '', | |
| type: 'text', | |
| isValid: val => /^[^@]+@[^@]+$/.test(val), | |
| }, | |
| ok: { | |
| label: '确认', | |
| color: 'primary', | |
| }, | |
| cancel: { | |
| label: '取消', | |
| color: 'secondary', | |
| }, | |
| }).onOk(resolve).onCancel(() => resolve(null)); | |
| return promise; | |
| }, | |
| async callback(e) { | |
| const qinput = instance.$refs.textarea; | |
| const textarea = qinput.nativeEl; | |
| const start = textarea.selectionStart; | |
| const end = textarea.selectionEnd; | |
| const selectedText = instance.value.substring(start, end); | |
| if (selectedText) { | |
| // 有选中文本:直接用选中文本作为email地址 | |
| const before = instance.value.substring(0, start); | |
| const after = instance.value.substring(end); | |
| instance.value = before + `[email]${selectedText}[/email]` + after; | |
| // 选中标签内的内容 | |
| const newStart = start + `[email]`.length; | |
| const newEnd = newStart + selectedText.length; | |
| setTimeout(() => textarea.setSelectionRange(newStart, newEnd)); | |
| } else { | |
| // 没有选中文本:弹窗输入email地址 | |
| const email = await this.prompt(); | |
| if (!email) return null; | |
| const before = instance.value.substring(0, start); | |
| const after = instance.value.substring(start); | |
| instance.value = before + `[email]${email}[/email]` + after; | |
| // 将光标置于标签中间 | |
| const cursorPos = start + `[email]`.length; | |
| setTimeout(() => textarea.setSelectionRange(cursorPos, cursorPos)); | |
| } | |
| textarea.focus(); | |
| return null; | |
| } | |
| }, { | |
| icon: 'image', | |
| title: Buttons.Image, | |
| async prompt() { | |
| const { promise, reject, resolve } = Promise.withResolvers(); | |
| Quasar.Dialog.create({ | |
| title: '插入图片', | |
| message: '插入图片的链接:', | |
| prompt: { | |
| model: '', | |
| type: 'text', | |
| isValid: val => /\.(jpg|jpeg|png|webp|gif)/.test(val), | |
| }, | |
| ok: { | |
| label: '确认', | |
| color: 'primary', | |
| }, | |
| cancel: { | |
| label: '取消', | |
| color: 'secondary', | |
| }, | |
| }).onOk(resolve).onCancel(() => resolve(null)); | |
| return promise; | |
| }, | |
| async callback(e) { | |
| const qinput = instance.$refs.textarea; | |
| const textarea = qinput.nativeEl; | |
| const imageUrl = await this.prompt(); | |
| if (!imageUrl) return null; | |
| const start = textarea.selectionStart; | |
| const end = textarea.selectionEnd; | |
| const selectedText = instance.value.substring(start, end); | |
| if (selectedText) { | |
| // 有选中文本,使用用户输入的URL替换选中文本 | |
| const before = instance.value.substring(0, start); | |
| const after = instance.value.substring(end); | |
| instance.value = before + ` ${imageUrl} ` + after; | |
| // 选中插入的图片代码 | |
| textarea.setSelectionRange(start, start + imageUrl.length + 2); | |
| } else { | |
| // 没有选中文本,插入URL到光标位置 | |
| const before = instance.value.substring(0, start); | |
| const after = instance.value.substring(start); | |
| instance.value = before + ` ${imageUrl} ` + after; | |
| // 将光标移到插入的图片代码后面 | |
| textarea.setSelectionRange(start + imageUrl.length + 2, start + imageUrl.length + 2); | |
| } | |
| textarea.focus(); | |
| return null; | |
| } | |
| }, { | |
| component(app) { | |
| app.component('PDialogEmojiselector', { | |
| name: 'PDialogEmojiselector', | |
| props: [''], | |
| emits: ['update'], | |
| template: ` | |
| <!-- 按钮 --> | |
| <q-btn | |
| icon="emoji_emotions" | |
| @click="show" | |
| flat | |
| v-bind="$attrs" | |
| > | |
| <q-tooltip | |
| anchor="top middle" | |
| self="bottom middle" | |
| >${ Buttons.Emoji }</q-tooltip> | |
| </q-btn> | |
| <!-- 弹窗 --> | |
| <q-dialog v-model="visible"> | |
| <q-card> | |
| <q-card-section class="column"> | |
| <div v-for="row of emojis" | |
| class="row" | |
| > | |
| <q-btn v-for="emoji of row" | |
| @click="submit(emoji[0])" | |
| flat round | |
| > | |
| <img :src="'/images/smiles/' + emoji[1]"> | |
| <q-tooltip | |
| anchor="top middle" | |
| self="bottom middle" | |
| > | |
| {{ emoji[2] }} | |
| </q-tooltip> | |
| </q-btn> | |
| </div> | |
| </q-card-section> | |
| <q-card-actions align="right"> | |
| <q-btn @click="cancel" flat>${ Components.PDialogFontcolor.Cancel }</q-btn> | |
| </q-card-actions> | |
| </q-card> | |
| </q-dialog> | |
| `, | |
| data() { | |
| return { | |
| visible: false, | |
| }; | |
| }, | |
| methods: { | |
| /** 展示弹窗 */ | |
| show() { | |
| this.visible = true; | |
| }, | |
| /** | |
| * 提交表情代码到编辑框 | |
| * 注:此组件和一般组件不同,不设常态记录表情代码的变量,而是在点击表情按钮的同时确定代码并提交 | |
| * @param {string} emoji_code - 提交的表情代码 | |
| */ | |
| submit(emoji_code) { | |
| const qinput = instance.$refs.textarea; | |
| /** @type {HTMLTextAreaElement} */ | |
| const textarea = qinput.nativeEl; | |
| // 插入Emoji代码(不需要前后空格) | |
| const start = textarea.selectionStart; | |
| const before = instance.value.substring(0, start); | |
| const after = instance.value.substring(start); | |
| instance.value = before + emoji_code + after; | |
| // 将光标移到插入的表情后面 | |
| setTimeout(() => { | |
| textarea.setSelectionRange(start + emoji_code.length, start + emoji_code.length); | |
| textarea.focus() | |
| }); | |
| this.$emit('update'); | |
| this.visible = false; | |
| }, | |
| }, | |
| computed: { | |
| /** 五行四列的嵌套数组(5, 4),包含文库表情数据 */ | |
| emojis() { | |
| const data = CONST.Internal.WenkuEmojis; | |
| const emojis = []; | |
| for (let i = 0; i < data.length; i += 4) { | |
| const row = []; | |
| row.push(...data.slice(i, i + 4)); | |
| emojis.push(row); | |
| } | |
| return emojis; | |
| } | |
| }, | |
| }); | |
| return 'p-dialog-emojiselector'; | |
| } | |
| }, { | |
| icon: 'format_align_left', | |
| title: Buttons.Align.Left, | |
| async callback(e) { | |
| return applyBBCodeTag('align', 'left', true); | |
| } | |
| }, { | |
| icon: 'format_align_center', | |
| title: Buttons.Align.Center, | |
| async callback(e) { | |
| return applyBBCodeTag('align', 'center', true); | |
| } | |
| }, { | |
| icon: 'format_align_right', | |
| title: Buttons.Align.Right, | |
| async callback(e) { | |
| return applyBBCodeTag('align', 'right', true); | |
| } | |
| }, { | |
| component(app) { | |
| app.component('PDialogHistory', { | |
| name: 'PDialogHistory', | |
| props: ['selected-id'], | |
| emits: ['update', 'draftload'], | |
| template: ` | |
| <!-- 按钮 --> | |
| <q-btn | |
| icon="history" | |
| @click="show" | |
| flat | |
| v-bind="$attrs" | |
| > | |
| <q-tooltip | |
| anchor="top middle" | |
| self="bottom middle" | |
| >${ Buttons.History }</q-tooltip> | |
| </q-btn> | |
| <!-- 弹窗 --> | |
| <q-dialog v-model="visible"> | |
| <q-card style="width: 80vw; min-width: 40em;"> | |
| <!-- 标题栏 --> | |
| <q-card-section> | |
| <q-toolbar> | |
| <q-avatar icon="history"></q-avatar> | |
| <q-toolbar-title> | |
| ${ Components.PDialogHistory.Title } | |
| </q-toolbar-title> | |
| <q-btn icon="close" v-close-popup flat></q-btn> | |
| </q-toolbar> | |
| </q-card-section> | |
| <!-- 主要内容 --> | |
| <q-card-section> | |
| <!-- 草稿列表 --> | |
| <q-card-section class="q-pa-none"> | |
| <q-list style="max-height: 60vh; overflow: auto;"> | |
| <q-item v-for="draft of sorted_drafts" | |
| :class="{ 'bg-primary': selected && selected.id === draft.id }" | |
| @click="select(draft.id)" | |
| @dblclick="loadSelected" | |
| clickable | |
| > | |
| <!-- 简要内容 --> | |
| <q-item-section> | |
| <q-item-label v-if="draft.title" lines="1">{{ draft.title }}</q-item-label> | |
| <q-item-label v-if="draft.content" lines="2" caption> | |
| {{ (false && draft.content.length > 20) ? draft.content.substring(0, 20) + '...' : draft.content }} | |
| </q-item-label> | |
| </q-item-section> | |
| <!-- 操作按钮 --> | |
| <q-item-section side> | |
| <q-btn icon="delete_forever" @click="userRemove(draft.id)" dense flat></q-btn> | |
| </q-item-section> | |
| </q-item> | |
| </q-list> | |
| </q-card-section> | |
| </q-card-section> | |
| <!-- 底部操作按钮 --> | |
| <q-card-actions align="right"> | |
| <q-btn v-close-popup label=${ escJsStr(Components.PDialogHistory.Cancel) }></q-btn> | |
| <q-btn @click="loadSelected" label=${ escJsStr(Components.PDialogHistory.Ok) }></q-btn> | |
| </q-card-actions> | |
| </q-card> | |
| </q-dialog> | |
| `, | |
| data() { | |
| return { | |
| /** 弹窗显示状态 */ | |
| visible: false, | |
| /** | |
| * 草稿数据,在mounted中添加监听器与GM存储保持同步 | |
| * @type {Draft[]} | |
| */ | |
| drafts: GM_getValue('drafts'), | |
| /** | |
| * 当前被选中的草稿条目 | |
| * @type {Draft | null} | |
| */ | |
| selected: null, | |
| }; | |
| }, | |
| methods: { | |
| /** 显示弹窗 */ | |
| show() { | |
| this.visible = true; | |
| }, | |
| /** | |
| * 选中一项 | |
| * @param {string} id | |
| */ | |
| select(id) { | |
| /** @type {Draft[]} */ | |
| const drafts = this.sorted_drafts; | |
| this.selected = drafts.find(d => d.id === id); | |
| }, | |
| /** 加载选中项到编辑器 */ | |
| loadSelected() { | |
| // 未选中任何条目 | |
| if (!this.selected) return; | |
| /** @type {Draft} */ | |
| const draft = this.selected; | |
| instance.value = draft.content; | |
| draft.title && (instance.title = draft.title); | |
| this.visible = false; | |
| this.$emit('draftload', draft.id); | |
| this.$emit('update'); | |
| }, | |
| /** | |
| * 用户移除一条草稿 | |
| * @param {string} id | |
| */ | |
| userRemove(id) { | |
| /** @type {Draft[]} */ | |
| const drafts = this.drafts; | |
| const draft = drafts.find(d => d.id === id); | |
| const PDialogHistory = Components.PDialogHistory; | |
| if (!draft) { | |
| Quasar.Notify.create({ | |
| type: 'error', | |
| message: PDialogHistory.RemoveTargetNotExist, | |
| group: 'ubbeditor.editor.history.remove_error', | |
| }); | |
| return; | |
| } | |
| const ConfirmRemove = PDialogHistory.ConfirmRemove; | |
| Quasar.Dialog.create({ | |
| title: ConfirmRemove.Title, | |
| message: ConfirmRemove.Message, | |
| ok: { | |
| label: ConfirmRemove.Ok, | |
| color: 'primary', | |
| }, | |
| cancel: { | |
| label: ConfirmRemove.Cancel, | |
| color: 'secondary', | |
| }, | |
| }).onOk(() => { | |
| /** @type {Draft[]} */ | |
| const drafts = GM_getValue('drafts'); | |
| const index = drafts.findIndex(d => d.id === id); | |
| const draft = drafts.splice(index, 1)[0]; | |
| GM_setValue('drafts', drafts); | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: PDialogHistory.Removed, | |
| group: 'ubbeditor.editor.history.remove_success', | |
| actions: [{ | |
| label: PDialogHistory.Undo, | |
| handler() { | |
| /** @type {Draft[]} */ | |
| const drafts = GM_getValue('drafts'); | |
| index < drafts.length ? | |
| drafts.splice(index, 0, draft) : | |
| drafts.append(draft); | |
| GM_setValue('drafts', drafts); | |
| } | |
| }], | |
| }); | |
| }); | |
| } | |
| }, | |
| computed: { | |
| /** | |
| * 返回排序后的草稿/历史记录项目列表 | |
| * 当前阶段,一律按照最后更新由近到远排序,后续可能增加用户自行选择的排序方式 | |
| * @returns {Draft[]} | |
| */ | |
| sorted_drafts() { | |
| /** @type {Draft[]} */ | |
| const drafts = this.drafts; | |
| return drafts.sort((d1, d2) => d2.last_edit - d1.last_edit); | |
| }, | |
| }, | |
| watch: { | |
| // 当传入的selected-id属性改变时,使selected的值匹配selected-id属性值 | |
| selectedId(new_val, old_val) { | |
| typeof this.selectedId === 'string' && this.select(new_val); | |
| }, | |
| // 当从隐藏状态重新显示窗口时,使selected的值匹配selected-id属性值 | |
| visible(new_val, old_val) { | |
| new_val && typeof this.selectedId === 'string' && this.select(this.selectedId); | |
| }, | |
| }, | |
| mounted() { | |
| const that = this; | |
| GM_addValueChangeListener('drafts', (key, old_val, new_val, remote) => { | |
| that.drafts = new_val; | |
| }); | |
| }, | |
| }); | |
| return 'p-dialog-history'; | |
| }, | |
| props: { | |
| 'selected-id': 'draft_id', | |
| }, | |
| listeners: { | |
| /** | |
| * 当用户加载某草稿/历史记录时触发此事件 | |
| * @param {string} id | |
| */ | |
| draftload(id) { | |
| instance.draft_id = id; | |
| } | |
| }, | |
| }]; | |
| /** | |
| * 在文本区域应用BBCode标签 | |
| * @param {string} tagName - BBCode标签名 | |
| * @param {string} [attributeValue] - 属性值(可选) | |
| * @param {boolean} [selectContent] - 是否选中标签内的内容 | |
| */ | |
| function applyBBCodeTag(tagName, attributeValue, selectContent = true) { | |
| const qinput = instance.$refs.textarea; | |
| /** @type {HTMLTextAreaElement} */ | |
| const textarea = qinput.nativeEl; | |
| const start = textarea.selectionStart; | |
| const end = textarea.selectionEnd; | |
| const selectedText = instance.value.substring(start, end); | |
| // 构建属性部分 | |
| const attrPart = attributeValue ? `=${attributeValue}` : ''; | |
| if (selectedText) { | |
| // 有选中文本 | |
| const before = instance.value.substring(0, start); | |
| const after = instance.value.substring(end); | |
| const openTag = `[${tagName}${attrPart}]`; | |
| const closeTag = `[/${tagName}]`; | |
| instance.value = before + openTag + selectedText + closeTag + after; | |
| if (selectContent) { | |
| // 选中标签内的内容 | |
| const newStart = start + openTag.length; | |
| const newEnd = newStart + selectedText.length; | |
| setTimeout(() => textarea.setSelectionRange(newStart, newEnd)); | |
| } else { | |
| // 选中整个标签(包括开闭标签) | |
| textarea.setSelectionRange(start, start + openTag.length + selectedText.length + closeTag.length); | |
| } | |
| } else { | |
| // 没有选中文本,插入空标签 | |
| const before = instance.value.substring(0, start); | |
| const after = instance.value.substring(start); | |
| const openTag = `[${tagName}${attrPart}]`; | |
| const closeTag = `[/${tagName}]`; | |
| instance.value = before + openTag + closeTag + after; | |
| // 将光标置于标签中间 | |
| const cursorPos = start + openTag.length; | |
| setTimeout(() => textarea.setSelectionRange(cursorPos, cursorPos)); | |
| } | |
| setTimeout(() => textarea.focus()); | |
| return null; | |
| } | |
| // 创建GUI | |
| let instance; | |
| const app = Vue.createApp({ | |
| data() { | |
| return { | |
| /** @type {EditorButton[]} */ | |
| buttons: buttons, | |
| /** @type {boolean} */ | |
| visible: false, | |
| /** @type {HTMLTextAreaElement} */ | |
| wenku_pcontent: null, | |
| /** @type {HTMLInputElement} */ | |
| wenku_ptitle: null, | |
| /** @type {string} */ | |
| title: '', | |
| /** @type {string} */ | |
| value: '', | |
| /** @type {string} */ | |
| preview_html: '', | |
| /** | |
| * 打开编辑器时的初始值,用于复原按钮 | |
| * @type {{ title: string, value: string }} | |
| */ | |
| backup: { title: '', value: '' }, | |
| /** 当前内容对应草稿/历史记录条目id */ | |
| draft_id: utils.randstr(16, true, GM_getValue('drafts').map(d => d.id)), | |
| /** @type {string} */ | |
| text_hint: '', | |
| }; | |
| }, | |
| methods: { | |
| /** | |
| * 编辑器按钮被点击回调 | |
| * @param {PointerEvent} e | |
| * @param {EditorButton} button | |
| */ | |
| async buttonClick(e, button) { | |
| const rval = await button.callback.call(button, e); | |
| typeof rval === 'string' && (this.value = rval); | |
| this.update(); | |
| }, | |
| /** | |
| * 在左下角展示提示文本 | |
| * @param {string} text | |
| */ | |
| showHint(text) { | |
| const that = this; | |
| this.text_hint = text; | |
| }, | |
| /** | |
| * 编辑框内容以任何形式改变时调用此方法 | |
| * - 更新预览 | |
| * - 同步内容到文库自带输入框 | |
| * - 自动保存到草稿/历史记录 | |
| */ | |
| update() { | |
| this.preview(); | |
| this.sync(); | |
| this.saveDraft(); | |
| this.showHint(replaceText( | |
| Editor.DraftSaved, | |
| { '{Time}': utils.getTimeText() } | |
| )); | |
| }, | |
| /** 编辑完毕,提交给文库自带输入框 */ | |
| submit() { | |
| Assert(this.wenku_pcontent, 'UBBEditor.enhance.editor.submit: instance.wenku_pcontent not set', TypeError); | |
| this.sync(); | |
| this.visible = false; | |
| }, | |
| /** 复原编辑框内容到刚刚打开编辑框时的初始内容 */ | |
| reset() { | |
| const ResetDialog = Editor.ResetDialog; | |
| Quasar.Dialog.create({ | |
| title: ResetDialog.Title, | |
| message: ResetDialog.Message, | |
| ok: { | |
| label: ResetDialog.Ok, | |
| color: 'primary', | |
| }, | |
| cancel: { | |
| label: ResetDialog.Cancel, | |
| color: 'secondary', | |
| }, | |
| }).onOk(() => { | |
| this.value = this.backup.value; | |
| this.has_title && (this.title = this.backup.title); | |
| this.update(); | |
| }); | |
| }, | |
| /** | |
| * 初始化编辑器,将编辑器与文库自带的输入框绑定 | |
| * @param {HTMLTextAreaElement} pcontent - 书评内容输入框 | |
| * @param {HTMLInputElement} [ptitle=null] - 标题输入框,无此框时可省略 | |
| */ | |
| init(pcontent, ptitle = null) { | |
| this.wenku_pcontent = pcontent; | |
| this.value = pcontent.value; | |
| if (ptitle) { | |
| this.wenku_ptitle = ptitle; | |
| this.title = ptitle.value; | |
| } else { | |
| this.wenku_ptitle = null; | |
| this.title = ''; | |
| } | |
| this.backup = { | |
| title: this.title, | |
| value: this.value, | |
| }; | |
| this.preview(); | |
| }, | |
| /** 将编辑框的内容同步到文库编辑框中 */ | |
| sync() { | |
| this.wenku_pcontent.value = this.value; | |
| this.has_title && (this.wenku_ptitle.value = this.title); | |
| }, | |
| /** 根据bbcode更新预览内容 */ | |
| preview() { | |
| const html = bbcode.bbcode2html(this.value); | |
| this.preview_html = html; | |
| }, | |
| /** 保存当前内容到对应的草稿/历史记录条目 */ | |
| saveDraft() { | |
| /** @type {string} */ | |
| const id = this.draft_id; | |
| /** @type {Draft[]} */ | |
| const drafts = GM_getValue('drafts'); | |
| const now = Date.now(); | |
| // 获取草稿/历史记录条目 | |
| /** @type {Draft} */ | |
| const draft = drafts.find(d => d.id === id) ?? { | |
| id, | |
| title: '', | |
| content: '', | |
| created: now, | |
| last_edit: now, | |
| pages: [], | |
| }; | |
| // 更新内容 | |
| draft.content = this.value; | |
| this.has_title && (draft.title = this.title); | |
| draft.last_edit = now; | |
| draft.pages.some(page => utils.isSameUrl(page.url, location.href)) || draft.pages.push({ | |
| title: getPageTitle(), | |
| url: location.href, | |
| }); | |
| draft.pages.splice(0, draft.pages.length - CONST.Internal.UBBEditorMaximumDraftPage); | |
| // 新建条目时,将条目添加到条目列表,并保证总条目数不超过最大设定数量 | |
| // 按照最后编辑时间降序排列保存,优先删除最不活跃的草稿条目 | |
| const max_drafts = GM_getValue('max_drafts'); | |
| drafts.includes(draft) || drafts.push(draft); | |
| drafts.sort((d1, d2) => d2.last_edit - d1.last_edit); | |
| max_drafts >= 0 && drafts.splice(max_drafts); | |
| // 保存 | |
| GM_setValue('drafts', drafts); | |
| /** | |
| * 获取当前页面的人类可读的、有意义的标题 | |
| * @returns {string} | |
| */ | |
| function getPageTitle() { | |
| const TitlePrefix = Editor.History.TitlePrefix; | |
| // 书评页面 | |
| if (location.pathname === '/modules/article/reviewshow.php') { | |
| // 页面内标题栏字符串的后缀子串和标签页标题的前缀子串的共同串 | |
| const dom_title = $('#content > table:nth-of-type(2) strong').innerText.trim(); | |
| const doc_title = document.title.trim(); | |
| let title; | |
| for (let len = 1; len < Math.min(dom_title.length, doc_title.length); len++) { | |
| const dom_substr = dom_title.substring(dom_title.length - len); | |
| const doc_substr = doc_title.substring(0, len); | |
| if (dom_substr === doc_substr) { | |
| title = dom_substr; | |
| break; | |
| } | |
| } | |
| if (!title) { | |
| logger.error('Error', 'ubbeditor.editor.history.saveDraft.getPageTitle: 无法找到页面内标题与标签页标题的共同子串,已回退使用页面内标题'); | |
| title = dom_substr; | |
| } | |
| return TitlePrefix.Review + title; | |
| } | |
| // 书籍页面 | |
| if ( | |
| /^\/book\/\d+\.htm$/.test(location.pathname) || | |
| location.pathname === '/modules/article/articleinfo.php' | |
| ) { | |
| const title = $('#content > div:nth-of-type(1) > table:nth-of-type(1) table td > span > b').innerText.trim(); | |
| return TitlePrefix.Book + title; | |
| } | |
| // 书评列表页面 | |
| if (location.pathname === '/modules/article/reviews.php') { | |
| // 标签页标题的前缀子串和页面内书籍链接文本字符串子串的最长共同串 | |
| const dom_title = $('#content > table:nth-of-type(1) table a[href^="/book/"]').innerText.trim(); | |
| const doc_title = document.title.trim(); | |
| let title; | |
| for (let len = 1; len < Math.min(dom_title.length, doc_title.length); len++) { | |
| const doc_substr = doc_title.substring(0, len); | |
| if (dom_title.includes(doc_substr)) { | |
| title = doc_substr; | |
| } else { | |
| break; | |
| } | |
| } | |
| return TitlePrefix.Review + title; | |
| } | |
| // 书评编辑页面 | |
| if (location.pathname === '/modules/article/reviewedit.php') { | |
| // 书评编辑页面内无任何有效信息可以用作标题,因此只能提供yid | |
| const str_yid = new URLSearchParams(location.search).get('yid'); | |
| return replaceText( | |
| TitlePrefix.Reviewedit, | |
| { '{yid}': str_yid } | |
| ); | |
| } | |
| // 其它页面 | |
| logger => logger.warn('Warn', 'ubbeditor.editor.history.saveDraft: unknown page type'); | |
| return TitlePrefix.Unknown + document.title; | |
| } | |
| }, | |
| /** | |
| * QInput model-value更新事件处理器 | |
| * @param {Event} e | |
| */ | |
| onUpdate(e) { | |
| this.update(); | |
| }, | |
| }, | |
| computed: { | |
| /** | |
| * 是否存在书评标题框 | |
| * @returns {boolean} | |
| */ | |
| has_title() { | |
| return !!this.wenku_ptitle; | |
| } | |
| }, | |
| watch: { | |
| // 当窗口隐藏/显示状态改变时,不再继续关联之前的草稿/历史记录条目 | |
| visible(new_val, old_val) { | |
| // 关联全新的草稿/历史记录条目(创建新条目) | |
| this.draft_id = utils.randstr(16, true, GM_getValue('drafts').map(d => d.id)); | |
| } | |
| }, | |
| mounted() { | |
| instance = this; | |
| }, | |
| }); | |
| // 注册按钮组件,合成按钮的模板代码 | |
| const component_template = (await Promise.all(buttons.map(async (btn, i) => { | |
| // 已停用的按钮 | |
| if (btn.disabled) return `<!-- 已停用的按钮[${ i }] -->`; | |
| // 以自定义组件实现的按钮 | |
| if (btn.component) { | |
| // 注册该组件 | |
| const name = await Promise.resolve(btn.component(app)); | |
| // 传入自定义属性的模板代码 | |
| /** @type {Record<string, string>} */ | |
| const props = btn.props ?? {}; | |
| const props_template = Object.entries(props).map(([prop_name, val_name]) => `:${ prop_name }=${ escJsStr(val_name) }`).join(' '); | |
| // 监听自定义事件的模板代码 | |
| /** @type {Record<string, Function>} */ | |
| const listeners = btn.listeners ?? {}; | |
| const listeners_template = Object.entries(listeners).map(([name, handler]) => `@${ name }=${ escJsStr(`buttons[${ i }].listeners[${ escJsStr(name, "'") }]`, '"') }`).join(' '); | |
| // 合成组件调用模板 | |
| const template = `<${name} ${props_template} class="q-px-sm" flat @update="update" ${listeners_template}></${name}>`; | |
| return template; | |
| } | |
| // 以简单配置实现的按钮 | |
| return ` | |
| <q-btn | |
| icon=${ escJsStr(btn.icon) } | |
| @click="e => buttonClick(e, buttons[${i}])" | |
| class="q-px-sm" | |
| flat | |
| > | |
| <q-tooltip | |
| v-if=${ escJsStr(btn.title) } | |
| anchor="top middle" | |
| self="bottom middle" | |
| >${ btn.title }</q-tooltip> | |
| </q-btn> | |
| `; | |
| }))).join('\n'); | |
| const container = $CrE('div'); | |
| container.innerHTML = ` | |
| <q-dialog v-model="visible" full-width full-height class="plus-bbcode-editor"> | |
| <q-layout container view="hHh lpR fFf"> | |
| <q-header bordered class="bg-primary text-white" height-hint="98"> | |
| <q-toolbar> | |
| <q-toolbar-title> | |
| <q-icon name="book" class="q-px-sm"></q-icon> | |
| ${ Editor.Title } | |
| </q-toolbar-title> | |
| <q-btn icon="close" v-close-popup flat></q-btn> | |
| </q-toolbar> | |
| </q-header> | |
| <q-page-container> | |
| <q-page> | |
| <q-card square class="bbcode-editor-container q-pa-md"> | |
| <!-- 标题区域 --> | |
| <q-card-section v-if="has_title"> | |
| <q-item> | |
| <q-item-section avatar> | |
| <q-item-label class="text-body1">${ Editor.ReviewTitle }</q-item-label> | |
| </q-item-section> | |
| <q-item-section> | |
| <q-input | |
| v-model="title" | |
| square | |
| filled | |
| dense | |
| @update:model-value="onUpdate" | |
| ></q-input> | |
| </q-item-section> | |
| </q-item> | |
| </q-card-section> | |
| <!-- 分割线 --> | |
| <q-separator v-if="has_title" inset></q-separator> | |
| <!-- 内容区域 --> | |
| <q-card-section class="q-pa-none" style="flex-grow: 1; flex-shrink: 1; overflow: auto;"> | |
| <!-- 预览 --> | |
| <q-card-section style="height: 50%;"> | |
| <div | |
| style="height: 100%; overflow: auto;" | |
| v-html="preview_html" | |
| ></div> | |
| </q-card-section> | |
| <!-- 分割线 --> | |
| <q-separator inset></q-separator> | |
| <!-- 编辑器 --> | |
| <q-card-section style="height: 50%; flex-wrap: nowrap;" class="column"> | |
| <!-- 按钮 --> | |
| <div class="q-pb-sm"> | |
| ${ component_template } | |
| </div> | |
| <!-- 编辑框 --> | |
| <q-input | |
| filled | |
| autofocus | |
| hide-bottom-space | |
| type="textarea" | |
| ref="textarea" | |
| v-model="value" | |
| debounce="1000" | |
| style="flex-grow: 1;" | |
| class="plus-editor-qinput" | |
| input-style="height: 100%;" | |
| @update:model-value="onUpdate" | |
| ></q-input> | |
| </q-card-section> | |
| </q-card-section> | |
| </q-card> | |
| </q-page> | |
| </q-page-container> | |
| <q-footer bordered class="text-lightdark bg-lightdark"> | |
| <q-toolbar> | |
| <span>{{ text_hint }}</span> | |
| <q-space></q-space> | |
| <q-btn label=${ escJsStr(Editor.Reset) } icon="replay" @click="reset" flat></q-btn> | |
| <q-btn label=${ escJsStr(Editor.Ok) } icon="check" @click="submit" flat></q-btn> | |
| </q-toolbar> | |
| </q-footer> | |
| </q-layout> | |
| </q-dialog> | |
| `; | |
| document.body.append(container); | |
| addStyle(` | |
| .plus-bbcode-editor .bbcode-editor-container { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| /* 编辑框高度拉伸,填满q-input内部全部高度 */ | |
| .plus-editor-qinput *:not(#important) { | |
| height: 100%; | |
| } | |
| `); | |
| app.use(Quasar); | |
| app.mount(container); | |
| /** | |
| * @param {HTMLTextAreaElement} pcontent - 文库的编辑框,最终编辑完毕的bbcode将回填至这里 | |
| * @param {HTMLInputElement | null} [ptitle=null] - 文库的标题编辑框,UI中的标题内容将回填至这里;无此框时可省略 | |
| */ | |
| function show(pcontent, ptitle = null) { | |
| instance.init(pcontent, ptitle); | |
| instance.visible = true; | |
| } | |
| function hide() { | |
| instance.visible = false; | |
| } | |
| return { show, hide }; | |
| } | |
| } | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_getValue }); | |
| await promise; | |
| /** @type {editor} */ | |
| const editor = pool.require('editor'); | |
| // 自动将修复与增强功能应用于已知的页面内自带UBBEditor实例 | |
| const pages = [{ | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/reviewshow.php' | |
| }, | |
| selector: 'form[name="frmreview"]' | |
| }, { | |
| checkers: [{ | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, { | |
| type: 'startpath', | |
| value: '/modules/article/articleinfo.php' | |
| }], | |
| selector: 'form[name="frmreview"]' | |
| }, { | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/reviews.php' | |
| }, | |
| selector: 'form[name="frmreview"]' | |
| }, { | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/reviewedit.php' | |
| }, | |
| selector: 'form[name="frmreview"]' | |
| }]; | |
| pages.forEach(page => FunctionLoader.testCheckers(page.checkers) && | |
| detectDom(page.selector).then(form => enhance(form))); | |
| /** | |
| * 将修复与增强功能应用于UBBEditor实例 | |
| * @param {HTMLFormElement} form - 存放UBBEditor的form,通常是[name="frmreview"],无需等待其中的UBBEditor加载初始化完毕 | |
| * @returns {Promise<void>} | |
| */ | |
| async function enhance(form) { | |
| await Promise.all([ | |
| // 样式表式修复 | |
| (async function() { | |
| const style = ` | |
| textarea[name="pcontent"] { | |
| padding: 0.25em; | |
| width: calc(100% - 8px); | |
| } | |
| `; | |
| const id = 'plus-ubbeditor-enhance'; | |
| const root = form.getRootNode(); | |
| if (root.nodeName === '#document') { | |
| const head = await detectDom(root, 'head'); | |
| addStyle(head, style, id); | |
| } else { | |
| form.after($$CrE({ | |
| tagName: 'style', | |
| props: { | |
| innerHTML: style, | |
| id: id, | |
| }, | |
| })); | |
| } | |
| }) (), | |
| // 重写插入图片 | |
| detectDom(form, '#menuItemInsertImage').then( | |
| /** @param {HTMLInputElement} input */ | |
| input => $AEL(input, 'click', async e => { | |
| e.stopImmediatePropagation(); | |
| const InsertImage = CONST.Text.Review.UBBEditor.InsertImage; | |
| let url = await prompt({ | |
| message: InsertImage.InputUrl + '<br>' + InsertImage.UrlFormatTip, | |
| title: InsertImage.Title, | |
| html: true, | |
| ok: InsertImage.Ok, | |
| cancel: InsertImage.Cancel, | |
| isValid(url) { return isValidImageUrl(url.trim()); }, | |
| }); | |
| if (url === null) { return; } | |
| url = url.trim(); | |
| const textarea = $('#pcontent'); | |
| utils.insertText(textarea, url, true); | |
| textarea.focus(); | |
| }, { capture: true }) | |
| ), | |
| // 重写插入链接 | |
| detectDom(form, '#menuItemInsertUrl').then( | |
| /** @param {HTMLInputElement} input */ | |
| input => $AEL(input, 'click', async e => { | |
| e.stopImmediatePropagation(); | |
| const InsertUrl = CONST.Text.Review.UBBEditor.InsertUrl; | |
| let url = await prompt({ | |
| message: InsertUrl.InputUrl + '<br>' + InsertUrl.UrlFormatTip, | |
| title: InsertUrl.Title, | |
| html: true, | |
| ok: InsertUrl.Ok, | |
| cancel: InsertUrl.Cancel, | |
| isValid(url) { return isValidUrl(url.trim()); }, | |
| }); | |
| if (url === null) { return; } | |
| url = url.trim(); | |
| const textarea = $('#pcontent'); | |
| utils.insertText(textarea, `[url=${url}]${url}[/url]`); | |
| textarea.focus(); | |
| }, { capture: true }) | |
| ), | |
| // Ctrl/Meta + Enter键发表书评 | |
| detectDom(form, '#pcontent').then(pcontent => { | |
| $AEL(pcontent, 'keydown', e => { | |
| const os = GM_info.platform?.os ?? GM_info.userAgentData.platform; | |
| const is_mac = ['darwin', 'osx', 'mac'].some(str => os.includes(str)); | |
| if ((is_mac ? e.metaKey : e.ctrlKey) && e.code === 'Enter') { | |
| $(form, 'input[type="submit"][name="Submit"]')?.click(); | |
| } | |
| }); | |
| }), | |
| // 自适应高度 | |
| detectDom(form, '#pcontent').then( | |
| /** @param {HTMLTextAreaElement} pcontent */ | |
| pcontent => $AEL(pcontent, 'input', e => { | |
| const cur_height = parseInt(getComputedStyle(pcontent).height.match(/\d+/)[0], 10); | |
| // 跟deepseek学的:先设为auto以便正确计算pcontent.scrollHeight | |
| pcontent.style.height = 'auto'; | |
| // 根据当前输入框内部滚动高度和预设的上下限确定输入框新高度 | |
| let target_height = Math.min( | |
| CONST.Internal.EditorHeight.Max, | |
| Math.max( | |
| CONST.Internal.EditorHeight.Min, | |
| pcontent.scrollHeight | |
| ) | |
| ); | |
| // 仅自动增高,不自动缩小 | |
| target_height = cur_height < target_height ? target_height : cur_height; | |
| // 设置高度 | |
| pcontent.style.height = `${target_height}px`; | |
| }) | |
| ), | |
| // 菜单按钮title升级为tiptitle | |
| detectDom(form, '#UBB_Menu').then( | |
| /** @param {HTMLDivElement} pcontent */ | |
| menu => detectDom({ | |
| root: menu, | |
| selector: '.UBB_MenuItem', | |
| async callback(item) { | |
| if (!item.hasAttribute('title')) { return; } | |
| if (item.ownerDocument !== utils.window.document) { return; } | |
| const title = item.getAttribute('title'); | |
| item.removeAttribute('title'); | |
| /** @type {mousetip} */ | |
| const mousetip = await require('mousetip', true); | |
| mousetip.set(item, title); | |
| } | |
| }) | |
| ), | |
| // 预览功能 | |
| detectDom(form, 'input[name="Submit"]').then( | |
| /** @param {HTMLInputElement} input */ | |
| input => { | |
| input.after($$CrE({ | |
| tagName: 'input', | |
| props: { | |
| type: 'button', | |
| value: CONST.Text.UBBEditor.PreviewButton | |
| }, | |
| styles: { | |
| padding: '0 0.5em', | |
| marginLeft: '0.5em', | |
| }, | |
| classes: ['button', 'plus-preview-button'], | |
| listeners: [['click', async e => { | |
| /** @type {bbcode} */ | |
| const bbcode = await require('bbcode', true); | |
| const pcontent = await detectDom(form, '#pcontent'); | |
| const ptitle = $(form, '#ptitle'); | |
| const code = pcontent.value; | |
| const bbhtml = bbcode.bbcode2html(code); | |
| const html = `<div>${ bbhtml }</div>`; | |
| const PreviewDialog = CONST.Text.UBBEditor.PreviewDialog; | |
| const title = ptitle?.value ?? PreviewDialog.EmptyTitle; | |
| const message = pcontent.value.length ? html : PreviewDialog.EmptyContent; | |
| const use_html = !!pcontent.value.length; | |
| Quasar.Dialog.create({ | |
| title, message, | |
| html: use_html, | |
| ok: { | |
| label: CONST.Text.UBBEditor.PreviewDialog.Ok, | |
| color: 'primary', | |
| }, | |
| }); | |
| }]] | |
| })) | |
| } | |
| ), | |
| // 自行实现的编辑器 | |
| detectDom(form, '#pcontent').then( | |
| /** @param {HTMLTextAreaElement} pcontent */ | |
| async pcontent => { | |
| const ptitle = $(form, '#ptitle'); | |
| const preview = await detectDom(form, '.plus-preview-button'); | |
| preview.parentElement.append($$CrE({ | |
| tagName: 'input', | |
| props: { | |
| value: '打开编辑器', | |
| type: 'button', | |
| }, | |
| styles: { | |
| padding: '0 0.5em', | |
| float: 'right', | |
| }, | |
| classes: ['button', 'plus-editor-button'], | |
| listeners: [['click', e => editor.show(pcontent, ptitle)]], | |
| })); | |
| } | |
| ), | |
| ]); | |
| } | |
| /** | |
| * 检查给定链接是否为符合文库书评语法格式的图片链接 | |
| * @param {string} url | |
| * @returns {boolean} | |
| */ | |
| function isValidImageUrl(url) { | |
| const prefix_valid = url.startsWith('http://') || url.startsWith('https://'); | |
| const suffix_valid = /\.(jpe?g|a?png|gif|webp)$/.test(url); | |
| const url_valid = prefix_valid && suffix_valid; | |
| return url_valid; | |
| } | |
| /** | |
| * 检查给定链接是否为符合文库书评语法格式的链接 | |
| * @param {string} url | |
| * @returns {boolean} | |
| */ | |
| function isValidUrl(url) { | |
| const prefix_valid = url.startsWith('http://') || url.startsWith('https://'); | |
| const url_valid = prefix_valid; | |
| return url_valid; | |
| } | |
| /** | |
| * Quasar Dialog 实现的prompt | |
| * @param {Object} options | |
| * @param {string} options.message - 提示文本 | |
| * @param {string} [options.title] - 输入框标题 | |
| * @param {boolean} [options.html=false] - 提示文本是否为html(不安全) | |
| * @param {string} [options.ok] - 确认按钮文本 | |
| * @param {string} [options.cancel] - 取消按钮文本 | |
| * @param {string} [options.model=''] - 输入框初始值 | |
| * @param {(val: string) => boolean} options.isValid - 验证输入数据是否合法的方法 | |
| * @returns {Promise<string | null>} | |
| */ | |
| function prompt({ message, title, html, ok, cancel, model, isValid }) { | |
| const { promise, resolve } = Promise.withResolvers(); | |
| const options = { | |
| message, | |
| ok: { | |
| color: 'primary', | |
| }, | |
| cancel: { | |
| color: 'secondary', | |
| }, | |
| prompt: { | |
| model: model ?? '', | |
| isValid, | |
| }, | |
| }; | |
| title && (options.title = title); | |
| html && (options.html = html); | |
| ok && (options.ok.label = ok); | |
| cancel && (options.cancel.label = cancel); | |
| Quasar.Dialog.create(options).onOk(text => resolve(text)).onCancel(() => resolve(null)); | |
| return promise; | |
| } | |
| return { enhance }; | |
| } | |
| }, | |
| userpage: { | |
| desc: '用户信息页相关功能,目前就一个DOM解析器', | |
| checkers: { | |
| type: 'path', | |
| value: '/userpage.php' | |
| }, | |
| dependencies: ['utils'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.userpage.func>>} userpage */ | |
| async func() { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| // 注:这里的对象并非完整,按需开发即可 | |
| /** | |
| * 标准页面对象,由页面解析器生成 | |
| * @typedef {Object} UserPage | |
| * @property {UserElement} element | |
| * @property {UserData} data | |
| */ | |
| /** | |
| * {@link UserPage} 类型中的DOM元素 | |
| * @typedef {Object} UserElement | |
| * @property {HTMLDivElement} info - 会员信息block | |
| * @property {HTMLAnchorElement} avatar - 头像Img | |
| * @property {HTMLElement} name - 昵称strong | |
| * @property {UserLine[]} userlines - 会员信息板块信息行集合 | |
| * @property {UserButton[]} userbuttons - 会员信息板块操作按钮集合 | |
| * @property {HTMLUListElement} linecontainer - 会员信息板块信息行的父元素容器 | |
| * @property {HTMLUListElement} buttoncontainer - 会员信息板块操作按钮的父元素容器 | |
| */ | |
| /** | |
| * {@link UserPage} 类型中的数据 | |
| * @typedef {Object} UserData | |
| * @property {User} user | |
| */ | |
| /** | |
| * {@link UserElement} 类型中的一行信息行 | |
| * @typedef {Object} UserLine | |
| * @property {string} id - 信息行id,全局唯一 | |
| * @property {boolean} wenku - 是否为文库页面自带行 | |
| * @property {number} index - 按钮排序位置,升序排列,文库自带均为负数,新增按钮均为正数 | |
| * @property {HTMLLIElement} element - 对应的DOM节点 | |
| */ | |
| /** | |
| * {@link UserElement} 类型中的一个操作按钮 | |
| * @typedef {Object} UserButton | |
| * @property {string} id - 按钮id,全局唯一 | |
| * @property {boolean} wenku - 是否为文库页面自带按钮 | |
| * @property {number} index - 按钮排序位置,升序排列,文库自带均为负数,新增按钮均为正数 | |
| * @property {HTMLElement} element - 对应的DOM节点,应为li内部的按钮元素而非li节点 | |
| */ | |
| /** | |
| * {@link UserData} 类型中的用户数据 | |
| * @typedef {Object} User | |
| * @property {number} id | |
| * @property {string} name | |
| */ | |
| const pool_funcs = { | |
| PageManager: { | |
| desc: '管理页面对象实例及其解析与修改', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.PageManager.func>>} PageManager */ | |
| async func() { | |
| const pool_funcs = { | |
| parser: { | |
| desc: 'DOM解析器', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.parser.func>>} parser */ | |
| func() { | |
| /** | |
| * 将Document解析为标准用户页对象 | |
| * 仅可解析未被修改的原始文库页面 | |
| * @param {Document} [doc=document] - 被解析的文档,省略则默认为当前页面文档 | |
| * @returns {UserPage} | |
| */ | |
| function parse(doc = document) { | |
| const element = parseElement(doc); | |
| const data = parseData(element); | |
| return { element, data } | |
| } | |
| /** | |
| * 将Document解析为标准用户页对象的元素部分 | |
| * 仅可解析未被修改的原始文库页面 | |
| * @param {Document} [doc=document] - 被解析的文档,省略则默认为当前页面文档 | |
| * @returns {UserElement} | |
| */ | |
| function parseElement(doc = document) { | |
| const info = $(doc, '#left > .block:first-child'); | |
| const avatar = $(info, '.blockcontent .avatars'); | |
| const name = $(info, '.blockcontent .ulrow > li:nth-child(2)'); | |
| const linecontainer = $(info, '.blockcontent .ulrow'); | |
| const buttoncontainer = $(info, '.blockcontent > div > ul:nth-of-type(2)'); | |
| const userlines = getUserLines(); | |
| const userbuttons = getUserButtons(); | |
| return { info, avatar, name, userlines, userbuttons, linecontainer, buttoncontainer } | |
| function getUserLines() { | |
| return [...$All(info, '.blockcontent .ulrow > li')] | |
| .filter(li => !li.children.length) | |
| .map( | |
| /** | |
| * @param {HTMLLIElement} li | |
| * @param {number} i | |
| * @returns {UserLine} | |
| */ | |
| (li, i, list_items) => ({ | |
| id: ['type', 'level'][i], | |
| wenku: true, | |
| index: i - list_items.length, | |
| element: li | |
| }) | |
| ); | |
| } | |
| function getUserButtons() { | |
| return [...$All(info, '.blockcontent > div > :nth-child(2) > li > a')] | |
| .map( | |
| /** | |
| * @param {HTMLAnchorElement} a | |
| * @param {number} i | |
| * @returns {UserButton} | |
| */ | |
| (a, i, anchors) => ({ | |
| id: ['message', 'friend', 'detail'][i], | |
| wenku: true, | |
| index: i - anchors.length, | |
| element: a | |
| }) | |
| ) | |
| } | |
| } | |
| /** | |
| * 从标准用户页对象元素部分解析数据 | |
| * 仅可解析未被修改的原始文库页面 | |
| * @param {UserElement} element - 被解析的文档,省略则默认为当前页面文档 | |
| * @returns {UserData} | |
| */ | |
| function parseData(element) { | |
| /** @type {User} */ | |
| const user = { | |
| id: parseInt( | |
| new URLSearchParams( | |
| element.userbuttons | |
| .find(b => b.id === 'detail') | |
| .element.search | |
| ).get('id'), 10 | |
| ), | |
| name: element.name.innerText.trim() | |
| }; | |
| return { user }; | |
| } | |
| /** | |
| * 根据id获取指定信息行 | |
| * @param {UserPage} page | |
| * @param {string} id | |
| * @returns {UserLine | null} | |
| */ | |
| function getUserLine(page, id) { | |
| return page.element.userlines.find(l => l.id === id); | |
| } | |
| /** | |
| * 根据id获取指定操作按钮 | |
| * @param {UserPage} page | |
| * @param {string} id | |
| * @returns {UserButton | null} | |
| */ | |
| function getUserButton(page, id) { | |
| return page.element.userbuttons.find(b => b.id === id); | |
| } | |
| return { | |
| parse, | |
| getUserLine, getUserButton, | |
| } | |
| } | |
| }, | |
| transformer: { | |
| desc: '页面修改器', | |
| dependencies: 'parser', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.transformer.func>>} transformer */ | |
| func() { | |
| /** @type {parser} */ | |
| const parser = pool.require('parser'); | |
| /** | |
| * 在用户区下方新增一个按钮 | |
| * @param {UserPage} page | |
| * @param {Object} options | |
| * @param {string} options.id | |
| * @param {string} [options.label] - 按钮文字,和element二选一 | |
| * @param {string} [options.index] - 按钮的排序位置 | |
| * @param {function} [options.callback] - 按钮点击回调,和element二选一 | |
| * @param {HTMLElement} [options.element] - 按钮元素,和callback二选一 | |
| * @returns {FloorButton} | |
| */ | |
| function addUserButton(page, { id, label = null, index, callback = null, element = null }) { | |
| // 创建按钮元素 | |
| /** @type {HTMLDivElement} */ | |
| const container = $$CrE({ | |
| tagName: 'li', | |
| styles: { | |
| cssText: 'width:49%;float:left;' | |
| } | |
| }); | |
| const elm = element ?? $$CrE({ | |
| tagName: 'span', | |
| props: { innerText: label }, | |
| listeners: [['click', e => callback()]] | |
| }); | |
| elm.style.color = 'var(--q-primary)'; | |
| elm.style.cursor = 'pointer'; | |
| container.append(elm); | |
| // 添加按钮数据 | |
| const button = { | |
| id, | |
| wenku: false, | |
| index, | |
| element: elm | |
| }; | |
| const userbuttons = page.element.userbuttons; | |
| userbuttons.push(button); | |
| // 按照index排序并添加到页面 | |
| resortButtons(page); | |
| return button; | |
| } | |
| /** | |
| * 从用户区下方移除一个按钮 | |
| * @param {UserPage} page | |
| * @param {string} id | |
| * @returns {boolean} 是否移除成功,不成功可能是因为指定id的按钮不存在 | |
| */ | |
| function removeUserButton(page, id) { | |
| const userbuttons = page.element.userbuttons; | |
| const index = userbuttons.findIndex(btn => btn.id === id); | |
| if (index < 0) { return; } | |
| const button = userbuttons[index]; | |
| userbuttons.splice(index, 1); | |
| button.element.parentElement.remove(); | |
| // 按照index排序 | |
| resortButtons(page); | |
| } | |
| /** | |
| * 将page中的用户区的按钮按照index排序并重新添加到页面 | |
| * @param {UserPage} page | |
| */ | |
| function resortButtons(page) { | |
| const userbuttons = page.element.userbuttons; | |
| // 按照index排序 | |
| userbuttons.sort((b1, b2) => b1.index - b2.index); | |
| // 按照排好的顺序重新添加到页面 | |
| const parent = page.element.buttoncontainer; | |
| userbuttons.forEach(btn => parent.append(btn.element.parentElement)); | |
| } | |
| /** | |
| * 添加一行内容到会员信息的信息行中 | |
| * @param {UserPage} page - 用户页对象 | |
| * @param {Object} options | |
| * @param {string} options.id - 全局唯一,信息行id | |
| * @param {Node | string} options.line - 添加的内容,字符串将转换为文本节点添加 | |
| * @param {string} options.index - 信息行的排序位置 | |
| */ | |
| function addUserLine(page, { id, line, index }) { | |
| // 将字符串line转换为TextNode | |
| if (typeof line === 'string') { | |
| line = document.createTextNode(line); | |
| } | |
| // 使用li包装 | |
| const li = $CrE('li'); | |
| li.append(line); | |
| // 添加到楼层行数据中 | |
| /** @type {UserLine} */ | |
| const userline = { | |
| id, | |
| wenku: false, | |
| index, | |
| element: li, | |
| }; | |
| page.element.userlines.push(userline); | |
| // 按照index排序并添加到页面 | |
| resortLines(page); | |
| } | |
| /** | |
| * 更新一个已有用户信息行的内容 | |
| * @param {UserPage} page - 更新的楼层 | |
| * @param {string} id - 信息行id | |
| * @param {Node | string} line - 新的信息行内容,字符串将转换为文本节点 | |
| */ | |
| function updateLine(page, id, line) { | |
| // 将字符串line转换为TextNode | |
| if (typeof line === 'string') { | |
| line = document.createTextNode(line); | |
| } | |
| // 用li包装 | |
| const li = $CrE('li'); | |
| li.append(line); | |
| // 更新 | |
| const userline = parser.getUserLine(page, id); | |
| const previous_node = userline.element.previousSibling; | |
| previous_node.after(li); | |
| userline.element.remove(); | |
| userline.element = li; | |
| } | |
| /** | |
| * 移除一个已有用户信息行的内容 | |
| * @param {UserPage} page - 更新的楼层 | |
| * @param {string} id - 信息行id | |
| * @returns | |
| */ | |
| function removeLine(page, id) { | |
| const userline = parser.getUserLine(page, id); | |
| if (!userline) { return; } | |
| userline.element.remove(); | |
| const index = page.element.userlines.indexOf(userline); | |
| page.element.userlines.splice(index, 1); | |
| } | |
| /** | |
| * 将page中的用户区的信息行按照index排序并重新添加到页面 | |
| * @param {UserPage} page | |
| */ | |
| function resortLines(page) { | |
| const userlines = page.element.userlines; | |
| // 按照index排序 | |
| userlines.sort((b1, b2) => b1.index - b2.index); | |
| // 按照排好的顺序重新添加到页面 | |
| const parent = page.element.linecontainer; | |
| userlines.forEach(btn => parent.append(btn.element)); | |
| } | |
| return { | |
| addUserButton, removeUserButton, addUserLine, updateLine, removeLine | |
| }; | |
| } | |
| } | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs); | |
| await promise; | |
| /** @type {parser} */ | |
| const parser = pool.require('parser'); | |
| /** 当前页面的唯一页面对象实例,所有对页面的访问和修改都应围绕此实例进行 */ | |
| const page = parser.parse(); | |
| return { | |
| page, | |
| /** @type {parser} */ | |
| parser: pool.require('parser'), | |
| /** @type {transformer} */ | |
| transformer: pool.require('transformer'), | |
| } | |
| } | |
| }, | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs); | |
| await promise; | |
| return { | |
| /** @type {PageManager} */ | |
| PageManager: pool.require('PageManager'), | |
| } | |
| } | |
| }, | |
| userremark: { | |
| desc: '对用户进行备注的功能', | |
| checkers: [{ | |
| // 书评 | |
| type: 'path', | |
| value: '/modules/article/reviewshow.php' | |
| }, { | |
| // 用户主页 | |
| type: 'path', | |
| value: '/userpage.php' | |
| }], | |
| dependencies: ['debugging', 'utils', 'configs'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {configs} */ | |
| const configs = require('configs'); | |
| // 如果是发评论返回的提示页面,不继续运行 | |
| if ($All('.block').length === 1) { return; } | |
| GM_getValue = utils.defaultedGet({ | |
| /** @type {Record<string, string>} 字符串用户id - 用户备注 */ | |
| remarks: {}, | |
| enabled: true, | |
| }, GM_getValue); | |
| /** | |
| * 模块通讯信使,承担以下通讯任务: | |
| * - remarks更新消息 | |
| */ | |
| const messager = new EventTarget(); | |
| // 注册设置组 | |
| configs.registerConfig('remarks', { | |
| GM_addValueChangeListener, | |
| label: CONST.Text.UserRemark.Settings.Label, | |
| items: [{ | |
| type: 'boolean', | |
| label: CONST.Text.UserRemark.Settings.Enabled, | |
| caption: CONST.Text.UserRemark.Settings.EnabledCaption, | |
| key: 'enabled', | |
| reload: true, | |
| get() { return GM_getValue('enabled'); }, | |
| set(val) { return GM_setValue('enabled', val); }, | |
| }], | |
| }); | |
| // 实际功能函数,只有启用备注功能时才运行 | |
| const pool_funcs = { | |
| review: { | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/reviewshow.php' | |
| }, | |
| async func() { | |
| /** @type {review} */ | |
| const review = await require('review', true); | |
| const FloorManager = review.FloorManager; | |
| const floors = FloorManager.floors; | |
| // 显示用户备注 | |
| floors.forEach(floor => { | |
| addRemarkButton(floor); | |
| displayRemark(floor); | |
| }); | |
| $AEL(review.messager, 'update', e => { | |
| e.detail.floors.forEach(floor => { | |
| addRemarkButton(floor); | |
| displayRemark(floor); | |
| }); | |
| }); | |
| // 随用户备注更新显示 | |
| $AEL(messager, 'change', e => { | |
| /** @type { {id: number, remark: string} } */ | |
| const { id, remark } = e.detail; | |
| floors.filter(floor => floor.data.user.id === id).forEach(floor => { | |
| review.FloorManager.transformer.updateLine( | |
| floor, | |
| 'remark', | |
| getRemarkText(floor.data.user.id) | |
| ); | |
| }); | |
| }); | |
| /** @typedef {typeof review._types.Floor} Floor */ | |
| /** | |
| * 为评论楼层添加用户备注按钮 | |
| * @param {Floor} floor | |
| */ | |
| function addRemarkButton(floor) { | |
| review.FloorManager.transformer.addUserButton(floor, { | |
| id: 'remark', | |
| label: CONST.Text.UserRemark.RemarkUser, | |
| index: 1, | |
| callback() { | |
| promptRemark({ | |
| id: floor.data.user.id, | |
| name: floor.data.user.name | |
| }); | |
| } | |
| }); | |
| } | |
| /** | |
| * 为评论楼层的用户展示备注 | |
| * @param {Floor} floor | |
| */ | |
| function displayRemark(floor) { | |
| review.FloorManager.transformer.addUserLine(floor, { | |
| id: 'remark', | |
| line: getRemarkText(floor.data.user.id), | |
| base: 'type', | |
| position: 'before', | |
| }); | |
| } | |
| } | |
| }, | |
| userpage: { | |
| checkers: { | |
| type: 'path', | |
| value: '/userpage.php' | |
| }, | |
| async func() { | |
| /** @type {userpage} */ | |
| const userpage = await require('userpage', true); | |
| const page = userpage.PageManager.page; | |
| // 设置备注按钮 | |
| userpage.PageManager.transformer.addUserButton( | |
| page, { | |
| id: 'remark', | |
| label: CONST.Text.UserRemark.RemarkUser, | |
| index: 1, | |
| callback() { | |
| promptRemark({ | |
| id: page.data.user.id, | |
| name: page.data.user.name | |
| }); | |
| } | |
| } | |
| ); | |
| // 显示备注 | |
| userpage.PageManager.transformer.addUserLine( | |
| page, { | |
| id: 'remark', | |
| line: getRemarkText(page.data.user.id), | |
| index: 1, | |
| } | |
| ); | |
| // 随用户备注更新显示 | |
| $AEL(messager, 'change', e => { | |
| /** @type { {id: number, remark: string} } */ | |
| const { id, remark } = e.detail; | |
| userpage.PageManager.transformer.updateLine( | |
| page, | |
| 'remark', | |
| getRemarkText(page.data.user.id) | |
| ); | |
| }); | |
| } | |
| } | |
| }; | |
| if (GM_getValue('enabled')) { | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue }); | |
| await promise; | |
| } | |
| /** | |
| * 弹窗提示用户对指定用户设置备注 | |
| * @param {Object} user - 用户信息 | |
| * @param {number} user.id - 用户id | |
| * @param {string} [user.name] - 用户名 | |
| */ | |
| function promptRemark({ id, name = null }) { | |
| Quasar.Dialog.create({ | |
| title: CONST.Text.UserRemark.Prompt.Title, | |
| message: replaceText( | |
| CONST.Text.UserRemark.Prompt.Message, | |
| { '{Name}': name ?? id.toString() } | |
| ), | |
| prompt: { | |
| model: getRemark(id) ?? name ?? id, | |
| type: 'text', | |
| color: 'primary', | |
| }, | |
| ok: { | |
| label: CONST.Text.UserRemark.Prompt.Ok, | |
| color: 'primary', | |
| }, | |
| cancel: { | |
| label: CONST.Text.UserRemark.Prompt.Cancel, | |
| color: 'secondary', | |
| }, | |
| }).onOk(remark => { | |
| setRemark(id, remark); | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: CONST.Text.UserRemark.Prompt.Saved, | |
| caption: remark, | |
| group: 'remark.remark-saved', | |
| }); | |
| }); | |
| } | |
| /** | |
| * 获取对用户的备注 | |
| * @param {number} id - 用户id | |
| */ | |
| function getRemark(id) { | |
| const str_id = id.toString(); | |
| const remarks = GM_getValue('remarks'); | |
| return remarks.hasOwnProperty(str_id) ? remarks[str_id] : null; | |
| } | |
| /** | |
| * 设置用户的备注 | |
| * @param {number} id - 用户id | |
| * @param {string} remark - 备注内容 | |
| */ | |
| function setRemark(id, remark) { | |
| const str_id = id.toString(); | |
| const remarks = GM_getValue('remarks'); | |
| if (remark) { | |
| remarks[str_id] = remark; | |
| } else { | |
| delete remarks[str_id]; | |
| } | |
| GM_setValue('remarks', remarks); | |
| messager.dispatchEvent(new CustomEvent('change', { | |
| detail: { id, remark } | |
| })); | |
| } | |
| /** | |
| * 获取用户备注在UI中显示的文本 | |
| * 形如: "用户备注: 备注内容" / "未设置用户备注" | |
| */ | |
| function getRemarkText(id) { | |
| const remark = getRemark(id); | |
| return remark ? replaceText( | |
| CONST.Text.UserRemark.RemarkDisplay, | |
| { '{Remark}': remark } | |
| ) : CONST.Text.UserRemark.RemarkNotSet; | |
| } | |
| return { | |
| get remarks() { return GM_getValue('remarks') }, | |
| getRemark, setRemark, | |
| } | |
| } | |
| }, | |
| userreview: { | |
| desc: '查看用户书评', | |
| checkers: [{ | |
| // 书评 | |
| type: 'path', | |
| value: '/modules/article/reviewshow.php' | |
| }, { | |
| // 用户主页 | |
| type: 'path', | |
| value: '/userpage.php' | |
| }], | |
| dependencies: ['debugging', 'utils', 'configs'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {configs} */ | |
| const configs = require('configs'); | |
| GM_getValue = utils.defaultedGet({ | |
| enabled: true, | |
| }, GM_getValue); | |
| // 如果是发评论返回的提示页面,不继续运行 | |
| if ($All('.block').length === 1) { return; } | |
| // 实际功能函数,只有启用备注功能时才运行 | |
| const pool_funcs = { | |
| review: { | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/reviewshow.php' | |
| }, | |
| async func() { | |
| /** @type {review} */ | |
| const review = await require('review', true); | |
| const FloorManager = review.FloorManager; | |
| const floors = FloorManager.floors; | |
| // 显示用户备注 | |
| floors.forEach(floor => { | |
| addReviewButton(floor); | |
| }); | |
| $AEL(review.messager, 'update', e => { | |
| e.detail.floors.forEach(floor => { | |
| addReviewButton(floor); | |
| }); | |
| }); | |
| /** @typedef {typeof review._types.Floor} Floor */ | |
| /** | |
| * | |
| * @param {Floor} floor | |
| */ | |
| function addReviewButton(floor) { | |
| review.FloorManager.transformer.addUserButton(floor, { | |
| id: 'user_review', | |
| label: CONST.Text.UserReview.CheckUserReviews, | |
| index: 2, | |
| element: $$CrE({ | |
| tagName: 'a', | |
| attrs: { | |
| href: `https://${ location.host }/modules/article/reviewslist.php?keyword=${ floor.data.user.id }`, | |
| target: '_blank', | |
| }, | |
| props: { | |
| innerText: CONST.Text.UserReview.CheckUserReviews, | |
| }, | |
| }), | |
| }); | |
| } | |
| } | |
| }, | |
| userpage: { | |
| checkers: { | |
| type: 'path', | |
| value: '/userpage.php' | |
| }, | |
| async func() { | |
| /** @type {userpage} */ | |
| const userpage = await require('userpage', true); | |
| const page = userpage.PageManager.page; | |
| // 设置备注按钮 | |
| const uid = parseInt(new URLSearchParams(location.search).get('uid'), 10); | |
| userpage.PageManager.transformer.addUserButton( | |
| page, { | |
| id: 'review', | |
| index: 2, | |
| element: $$CrE({ | |
| tagName: 'a', | |
| attrs: { | |
| href: `https://${ location.host }/modules/article/reviewslist.php?keyword=${ uid }`, | |
| target: '_blank', | |
| }, | |
| props: { | |
| innerText: CONST.Text.UserReview.CheckUserReviews, | |
| }, | |
| }), | |
| } | |
| ); | |
| } | |
| }, | |
| }; | |
| if (GM_getValue('enabled')) { | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue }); | |
| await promise; | |
| } | |
| } | |
| }, | |
| bookpage: { | |
| desc: '小说信息页功能增强', | |
| checkers: [{ | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/articleinfo.php' | |
| }], | |
| dependencies: ['utils'], | |
| async func() { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| const pool_funcs = { | |
| metacopy: { | |
| desc: '在小说信息页提供复制小说元标签的功能', | |
| detectDom: '.main.m_foot', | |
| func() { | |
| // 书名 | |
| const b = $('#content > div:first-of-type > table:first-of-type table b'); | |
| const name = b.innerText; | |
| const button = makeCopyButton(e => { | |
| GM_setClipboard(name, 'text/plain'); | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: CONST.Text.MetaCopy.Copied, | |
| caption: name, | |
| group: 'metacopy.copied', | |
| }); | |
| }); | |
| button.style.removeProperty('padding-left'); | |
| b.after(button); | |
| // 元标签 | |
| const tds = [...$All('#content > div:first-child > table:first-child > tbody > tr:last-child > td')]; | |
| tds.forEach(td => addCopyButton(td)); | |
| /** | |
| * @param {HTMLTableCellElement} td | |
| */ | |
| function addCopyButton(td) { | |
| const [key, val] = td.innerText.trim().split(':'); | |
| const button = makeCopyButton(e => { | |
| GM_setClipboard(val, 'text/plain'); | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: CONST.Text.MetaCopy.Copied, | |
| caption: val, | |
| group: 'metacopy.copied', | |
| }); | |
| }); | |
| td.insertAdjacentElement('beforeend', button); | |
| } | |
| /** | |
| * @param {(e: PointerEvent) => any} callback - 按钮回调 | |
| * @returns {HTMLSpanElement} | |
| */ | |
| function makeCopyButton(callback) { | |
| return $$CrE({ | |
| tagName: 'span', | |
| props: { | |
| innerText: CONST.Text.MetaCopy.CopyButton, | |
| }, | |
| styles: { | |
| color: 'var(--q-primary)', | |
| cursor: 'pointer', | |
| paddingLeft: '0.5em', | |
| }, | |
| listeners: [['click', callback]] | |
| }) | |
| } | |
| } | |
| }, | |
| tagjump: { | |
| desc: '点击标签跳转标签小说列表页', | |
| detectDom: '.main.m_foot', | |
| func() { | |
| const b = $('#content > div:first-of-type > table:nth-of-type(2) td:nth-of-type(2) > span.hottext:first-of-type > b'); | |
| Assert(b.innerText.toLowerCase().includes('tags'), 'bookpage.tagjump: Cannot find tags'); | |
| const str_tags = b.innerText.split(/[︰:]/)[1]; | |
| const tags = str_tags.split(/\s+/).filter(tag => !!tag); | |
| b.innerHTML = | |
| b.innerText.replace(str_tags, '') + | |
| tags.map(tag => `<a class="plus-tag" href="https://${ location.host }/modules/article/tags.php?t=${ $URL.encode(tag) }" target="_blank">${ tag }</a>`) | |
| .join(' '); | |
| addStyle(` | |
| .plus-tag:is(.plus-darkmode *, :not(.plus-darkmode *)) { | |
| color: var(--q-primary); | |
| } | |
| `); | |
| } | |
| }, | |
| details: { | |
| desc: '添加查看详情数据的功能', | |
| async func() { | |
| /** @type {sidepanel} */ | |
| const sidepanel = await require('sidepanel', true); | |
| /** @type {api} */ | |
| const api = await require('api', true); | |
| sidepanel.registerButton({ | |
| id: 'bookpage.details.details', | |
| label: CONST.Text.BookDetails.ShowDetails, | |
| icon: 'bar_chart', | |
| index: 3, | |
| async callback() { | |
| // 获取数据 | |
| const BookDetails = CONST.Text.BookDetails; | |
| const aid = parseInt( | |
| new URLSearchParams(location.search).get('id') ?? | |
| location.href.match(/book\/(\d+)\.htm/)?.[1], | |
| 10); | |
| const doc = await api.getNovelFullMeta({ aid }); | |
| const title = $(doc, 'data[name="Title"]').firstChild.nodeValue; | |
| const meta = [ | |
| 'DayHitsCount', | |
| 'TotalHitsCount', | |
| 'PushCount', | |
| 'FavCount', | |
| ].reduce((meta, key) => { | |
| const name = BookDetails.DataNames[key]; | |
| const val = parseInt($(doc, `data[name=${ escJsStr(key) }]`).getAttribute('value'), 10); | |
| meta[name] = val; | |
| return meta; | |
| }, {}); | |
| const message = Object.entries(meta).map(([name, val]) => `${name}: ${val}`).join('\n'); | |
| const html_message = `<div class="text-body1">${ message.replaceAll('\n', '<br>') }</div>`; | |
| const dialog_title = replaceText(BookDetails.Dialog.Title, { | |
| '{Name}': title, | |
| }); | |
| // Dialog输出 | |
| Quasar.Dialog.create({ | |
| title: dialog_title, | |
| message: html_message, | |
| html: true, | |
| ok: { | |
| label: BookDetails.Dialog.Ok, | |
| color: 'primary', | |
| }, | |
| cancel: { | |
| label: BookDetails.Dialog.Cancel, | |
| color: 'secondary', | |
| }, | |
| }).onCancel(() => GM_setClipboard(`${ dialog_title }\n${ message }`)); | |
| } | |
| }); | |
| } | |
| }, | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue }); | |
| await promise; | |
| }, | |
| }, | |
| bookcase: { | |
| desc: '书架相关功能', | |
| checkers: [{ | |
| type: 'path', | |
| value: '/modules/article/bookcase.php' | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/addbookcase.php' | |
| }], | |
| dependencies: ['utils'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.darkmode.func>>} darkmode */ | |
| async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** | |
| * 通信信使,通过派发CustomEvent传递消息,目前有以下事件: | |
| * - switch | |
| * 当用户切换页面上展示的书架时派发,如从默认书架切换至第一组书架 | |
| * - classid {number} | |
| * - old_form {HTMLFormElement} - 切换前显示的form元素 | |
| * - new_form {HTMLFormElement} - 切换后显示的form元素 | |
| * - update | |
| * 当书架刷新完成时派发,可以是用户主动刷新书架/执行某些书架修改后自动刷新等 | |
| * - classid {number} | |
| * - old_form {HTMLFormElement} - 数据更新前旧的form元素 | |
| * - new_form {HTMLFormElement} - 数据更新后新的form元素 | |
| * - rename | |
| * 当用户重命名书架时派发 | |
| * - classid {number} | |
| * - old_name {string} | |
| * - new_name {string} | |
| */ | |
| const messager = new EventTarget(); | |
| const pool_funcs = { | |
| collector: { | |
| desc: '多书架整合', | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/bookcase.php' | |
| }, | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.collector.func>>} collector */ | |
| async func() { | |
| // 获取所有书架页面 | |
| Quasar.Loading.show({ message: CONST.Text.Bookcase.Collector.FetchingBookcases }); | |
| let page_classid = parseInt(new URLSearchParams(location.search).get('classid') ?? '0', 10); | |
| const forms = await Promise.all([0, 1, 2, 3, 4, 5].map(async classid => { | |
| return classid === page_classid ? | |
| await detectDom('#checkform') : | |
| await fetchBookcase(classid); | |
| })); | |
| // 切换书架功能 | |
| Quasar.Loading.show({ message: CONST.Text.Bookcase.Collector.ArrangingBookcases }); | |
| /** @type {(classid: number) => void} */ | |
| const switchBookcase = classid => { | |
| // 切换form | |
| const cur_form = $('#checkform'); | |
| const form = forms[classid]; | |
| cur_form.after(form); | |
| cur_form.remove(); | |
| // 切换classid并更新到url | |
| page_classid = classid; | |
| const new_url = new URL(location.href); | |
| new_url.searchParams.set('classid', classid.toString()); | |
| history.replaceState(null, '', new_url.href); | |
| // 广播切换事件 | |
| messager.dispatchEvent(new CustomEvent('switch', { | |
| detail: { | |
| old_form: cur_form, | |
| new_form: form, | |
| classid, | |
| } | |
| })); | |
| }; | |
| /** @type {(form: HTMLFormElement, classid: number) => void} */ | |
| const connectSwitcher = (form, classid) => $AEL($(form, 'select[name="classlist"]'), 'change', e => { | |
| e.stopImmediatePropagation(); | |
| const select = e.target; | |
| const new_classid = parseInt(select.value, 10); | |
| select.value = classid.toString(); | |
| switchBookcase(new_classid); | |
| }, { capture: true }); | |
| applyToAllForms(connectSwitcher); | |
| // 页面内更新书架功能 | |
| /** @type {([classid]: number, [new_form]: HTMLFormElement) => Promise<void>} */ | |
| const updateBookcase = async (classid=null, new_form=null) => { | |
| Quasar.Loading.show({ message: CONST.Text.Bookcase.Collector.UpdatingBookcase }); | |
| // 如果不提供classid,则更新所有书架 | |
| if (classid === null) { | |
| await Promise.all(forms.map(async (form, classid) => updateBookcase(classid))); | |
| return; | |
| } | |
| // 获取新书架 | |
| const form = forms[classid]; | |
| new_form = new_form ?? await fetchBookcase(classid); | |
| forms[classid] = new_form; | |
| if (document.body.contains(form)) { | |
| form.after(new_form); | |
| form.remove(); | |
| } | |
| // 广播更新事件 | |
| messager.dispatchEvent(new CustomEvent('update', { | |
| detail: { | |
| old_form: form, | |
| new_form: new_form, | |
| classid, | |
| } | |
| })); | |
| Quasar.Loading.hide(); | |
| }; | |
| const convertActionsInpage = (form, classid) => { | |
| // 表单提交改为ajax提交 | |
| $AEL(form, 'submit', async e => { | |
| const form = e.target; | |
| // 记录当前操作的名称 | |
| const action_select = $(form, '#newclassid'); | |
| const action_val = action_select.value; | |
| const action_name = [...$All(action_select, 'option')] | |
| .find(option => option.value === action_val).innerText; | |
| // 提交时,阻止默认表单提交 | |
| e.preventDefault(); | |
| // 接管文库页面自带的submit钩子 | |
| e.stopImmediatePropagation(); | |
| const orig_checker = form.onsubmit; | |
| if (!await checkSubmit()) { return; } | |
| // ajax提交表单 | |
| Quasar.Loading.show({ message: CONST.Text.Bookcase.Collector.SubmitingChange }); | |
| const formdata = new FormData(form); | |
| const doc = await utils.requestDocument({ | |
| method: 'POST', | |
| url: `/modules/article/bookcase.php?classid=${page_classid}&ajax_gets=jieqi_contents`, | |
| data: utils.serializeFormData(formdata), | |
| headers: { | |
| 'content-type': 'application/x-www-form-urlencoded', | |
| 'referrer': location.href, | |
| }, | |
| }); | |
| const new_form = $(doc, '#checkform'); | |
| Quasar.Loading.hide(); | |
| // 更新书架 | |
| await Promise.all([ | |
| // 更新当前书架 | |
| updateBookcase(classid, new_form), | |
| // 如果有,更新相关书架 | |
| formdata.get('newclassid') ? updateBookcase(parseInt(formdata.get('newclassid'))) : Promise.resolve() | |
| ]); | |
| // 提示完成 | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: replaceText( | |
| CONST.Text.Bookcase.Collector.ActionFinished, | |
| { '{ActionName}': action_name } | |
| ), | |
| group: 'bookcase.moved' | |
| }); | |
| }, { capture: true }); | |
| // 移除书籍按钮改为ajax提交 | |
| [...$All(form, 'tbody > tr > td:last-child > a')].forEach(a => $AEL(a, 'click', async e => { | |
| e.preventDefault(); | |
| const bid = parseInt(new URLSearchParams(a.closest('tr').children[1].querySelector('a').search).get('bid'), 10); | |
| const bookname = a.closest('tr').children[1].querySelector('a').innerText.trim(); | |
| if (!await confirmRemove(bookname)) { return; } | |
| const doc = await utils.requestDocument({ | |
| method: 'GET', | |
| url: `/modules/article/bookcase.php?classid=${classid}&ajax_gets=jieqi_contents&ajax_request=${Date.now()}&delid=${bid}`, | |
| }); | |
| const new_form = $(doc, '#checkform'); | |
| updateBookcase(classid, new_form); | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: CONST.Text.Bookcase.Collector.Removed, | |
| caption: bookname, | |
| group: 'bookcase.book-removed', | |
| }); | |
| })); | |
| /** | |
| * 功能和文库自身的window.check_confirm一模一样,用于表单提交前检查和操作确认,但是用quasar提示框重写的 | |
| * @returns {Promise<boolean>} | |
| */ | |
| async function checkSubmit() { | |
| const form = $('#checkform'); | |
| // 检查是否未选中任何书籍 | |
| /** @type {string[]} 被选择的书名 */ | |
| const checked_books = [...$All(form, 'input[name="checkid[]"]')] | |
| .filter(check => check.checked) | |
| .map(check => check.closest('tr').children[1].querySelector('a').innerText.trim()); | |
| if (!checked_books.length) { | |
| Quasar.Notify.create({ | |
| type: 'error', | |
| message: CONST.Text.Bookcase.Collector.NoBooksSelected | |
| }); | |
| return false; | |
| } | |
| // 如果正在移除书籍,先进行确认 | |
| // 这里的 == 非全等号写法是在和文库自带函数代码保持一致,实际上value值应为'-1' | |
| if ($(form, '#newclassid').value == -1) { | |
| const book_names = checked_books.join('、'); | |
| return await confirmRemove(book_names); | |
| } else { | |
| return true; | |
| } | |
| } | |
| }; | |
| applyToAllForms(convertActionsInpage); | |
| // 侧边栏按钮 | |
| require('sidepanel', true).then( | |
| /** @param {sidepanel} sidepanel */ | |
| sidepanel => sidepanel.registerButton({ | |
| id: 'bookcase.refresh', | |
| icon: 'sync', | |
| label: CONST.Text.Bookcase.Collector.RefreshBookcase, | |
| index: 2, | |
| async callback() { | |
| await updateBookcase(); | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: CONST.Text.Bookcase.Collector.Refreshed, | |
| group: 'bookcase.bookcase-refreshed', | |
| }); | |
| }, | |
| }) | |
| ); | |
| Quasar.Loading.hide(); | |
| /** | |
| * 询问用户是否要将某一书籍移出书架 | |
| * @param {string} bookname | |
| * @returns {Promise<boolean>} | |
| */ | |
| function confirmRemove(bookname) { | |
| const { promise, resolve } = Promise.withResolvers(); | |
| const ConfirmRemove = CONST.Text.Bookcase.Collector.Dialog.ConfirmRemove; | |
| Quasar.Dialog.create({ | |
| message: replaceText( | |
| ConfirmRemove.Message, | |
| { '{Name}': bookname } | |
| ), | |
| title: ConfirmRemove.Title, | |
| ok: { | |
| label: ConfirmRemove.ok, | |
| color: 'primary', | |
| }, | |
| cancel: { | |
| label: ConfirmRemove.cancel, | |
| color: 'secondary', | |
| }, | |
| }).onOk(() => resolve(true)).onCancel(() => resolve(false)); | |
| return promise; | |
| } | |
| /** | |
| * 网络请求获取指定书架form元素 | |
| * @param {number} classid | |
| * @returns {Promise<HTMLFormElement>} | |
| */ | |
| async function fetchBookcase(classid) { | |
| const doc = await utils.requestDocument({ | |
| method: 'GET', | |
| url: `/modules/article/bookcase.php?classid=${classid}&ajax_gets=jieqi_contents&ajax_request=${Date.now()}`, | |
| }); | |
| return $(doc, '#checkform'); | |
| } | |
| /** | |
| * 将提供的方法对所有书架form元素执行一次,包括现有的form、未来更新创建的新form等全部form | |
| * @param {(form: HTMLFormElement, classid: number) => any} func | |
| */ | |
| function applyToAllForms(func) { | |
| forms.forEach((form, classid) => func(form, classid)); | |
| $AEL(messager, 'update', e => func(e.detail.new_form, e.detail.classid)); | |
| } | |
| return { | |
| // 数据 | |
| forms, | |
| get classid() { return page_classid; }, | |
| set classid(classid) { page_classid = classid; }, | |
| // 功能 | |
| switchBookcase, updateBookcase, | |
| // 底层-适合内部使用 | |
| connectSwitcher, convertActionsInpage, | |
| // 底层-适合外部使用 | |
| fetchBookcase, applyToAllForms, | |
| } | |
| } | |
| }, | |
| naming: { | |
| desc: '书架自命名', | |
| dependencies: 'collector', | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/bookcase.php' | |
| }, | |
| async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {collector} */ | |
| const collector = pool.require('collector'); | |
| const default_names = [ | |
| '默认书架', | |
| '第1组书架', | |
| '第2组书架', | |
| '第3组书架', | |
| '第4组书架', | |
| '第5组书架', | |
| ]; | |
| GM_getValue = utils.defaultedGet({ | |
| names: default_names, | |
| }, GM_getValue); | |
| // 当储存的names名称数据变化时,派发rename事件 | |
| GM_addValueChangeListener('names', (key, old_val, new_val, remote) => { | |
| for (let classid = 0; classid < 6; classid++) { | |
| const [old_name, new_name] = [ | |
| old_val?.[classid] ?? default_names[classid], | |
| new_val[classid] | |
| ]; | |
| old_name !== new_name && | |
| messager.dispatchEvent(new CustomEvent('rename', { | |
| detail: { | |
| old_name, | |
| new_name, | |
| classid, | |
| } | |
| })); | |
| } | |
| }); | |
| // 重命名按钮 | |
| collector.applyToAllForms((form, classid) => { | |
| const select = $(form, 'select[name="classlist"]'); | |
| const button = $$CrE({ | |
| tagName: 'span', | |
| props: { | |
| innerText: CONST.Text.Bookcase.Naming.Rename, | |
| }, | |
| styles: { | |
| border: '1px solid', | |
| padding: '3px', | |
| cursor: 'pointer', | |
| marginLeft: '0.5em', | |
| }, | |
| listeners: [['click', async e => { | |
| const name = await promptNewName(classid); | |
| name !== null && saveName(classid, name); | |
| }]] | |
| }); | |
| const icon = $$CrE({ | |
| tagName: 'i', | |
| classes: 'material-icons', | |
| props: { innerText: 'drive_file_rename_outline' }, | |
| styles: { | |
| verticalAlign: 'text-bottom', | |
| } | |
| }); | |
| button.insertAdjacentElement('afterbegin', icon); | |
| select.after(button); | |
| }); | |
| // 对每个书架应用用户设定的名称 | |
| collector.applyToAllForms((form, classid) => { | |
| const names = GM_getValue('names'); | |
| [...$All(form, 'select[name="classlist"] > option')].forEach((option, op_classid) => { | |
| option.innerText = names[op_classid]; | |
| }); | |
| [...$All(form, '#newclassid > option')].forEach(option => { | |
| const op_classid = parseInt(option.value, 10); | |
| if (op_classid >= 0) { | |
| option.innerText = replaceText( | |
| CONST.Text.Bookcase.Naming.MoveTo, | |
| { '{Name}': names[op_classid] } | |
| ); | |
| } | |
| }); | |
| }) | |
| // 重命名发生时修改GUI中的名称 | |
| $AEL(messager, 'rename', e => { | |
| collector.forms.forEach((form, classid) => { | |
| const switch_option = $(form, `select[name="classlist"] > option[value="${e.detail.classid}"]`); | |
| switch_option.innerText = e.detail.new_name; | |
| const move_option = $(form, `#newclassid > option[value="${e.detail.classid}"]`); | |
| move_option.innerText = replaceText( | |
| CONST.Text.Bookcase.Naming.MoveTo, | |
| { '{Name}': e.detail.new_name } | |
| ); | |
| }); | |
| }); | |
| /** | |
| * 向用户弹窗输入新的书架名字 | |
| * @param {number} classid | |
| * @returns {Promise<string | null>} 新名字,或者null(当用户点击取消时) | |
| */ | |
| function promptNewName(classid) { | |
| const { promise, resolve } = Promise.withResolvers(); | |
| const Naming = CONST.Text.Bookcase.Naming; | |
| const PromptNewName = Naming.Dialog.PromptNewName; | |
| const old_name = GM_getValue('names')[classid] ?? replaceText( | |
| Naming.DefaultName, | |
| { '{ClassID}': classid.toString() } | |
| ); | |
| Quasar.Dialog.create({ | |
| message: replaceText( | |
| PromptNewName.Message, | |
| { '{OldName}': old_name } | |
| ), | |
| title: PromptNewName.Title, | |
| prompt: { | |
| model: old_name, | |
| type: 'text', | |
| color: 'primary', | |
| }, | |
| ok: { | |
| label: PromptNewName.Ok, | |
| color: 'primary', | |
| }, | |
| cancel: { | |
| label: PromptNewName.Cancel, | |
| color: 'secondary' | |
| } | |
| }).onOk(new_name => resolve(new_name)).onCancel(() => resolve(null)); | |
| return promise; | |
| } | |
| function saveName(classid, name) { | |
| // 保存名称 | |
| const names = GM_getValue('names'); | |
| names[classid] = name; | |
| GM_setValue('names', names); | |
| } | |
| } | |
| }, | |
| addpagejump: { | |
| desc: '在“成功加入书架!”页面添加跳转到书架的按钮', | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/addbookcase.php', | |
| }, | |
| detectDom: '.blocknote', | |
| func() { | |
| const close_btn = $('a[href="javascript:window.close()"]'); | |
| const container = close_btn.parentElement; | |
| container.insertAdjacentText('afterbegin', ' '); | |
| container.insertAdjacentText('afterbegin', ']'); | |
| container.insertAdjacentElement('afterbegin', $$CrE({ | |
| tagName: 'a', | |
| attrs: { | |
| href: `/modules/article/bookcase.php`, | |
| }, | |
| props: { | |
| innerText: CONST.Text.Bookcase.AddpageJump.GotoBookcase | |
| }, | |
| })); | |
| container.insertAdjacentText('afterbegin', '['); | |
| } | |
| } | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { | |
| GM_setValue, GM_getValue, GM_addValueChangeListener | |
| }); | |
| await promise; | |
| }, | |
| }, | |
| readlater: { | |
| desc: '稍后再读', | |
| dependencies: ['utils', 'debugging', 'mousetip'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.darkmode.func>>} darkmode */ | |
| async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {debugging} */ | |
| const debugging = require('debugging'); | |
| /** @type {mousetip} */ | |
| const mousetip = require('mousetip'); | |
| GM_getValue = utils.defaultedGet({ | |
| /** @type {Book[]} */ | |
| list: [], | |
| }, GM_getValue); | |
| /** | |
| * @typedef {Object} Book | |
| * @property {number} aid | |
| * @property {string} name | |
| * @property {string} cover | |
| */ | |
| const pool_funcs = { | |
| core: { | |
| // 这里不用让FunctionLoader包装子存储,直接将list存储在readlater的全局作用域中即可 | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.core.func>>} core */ | |
| func() { | |
| // 内容更改监听器 | |
| /** @type {((val: Book[]) => any)[]} */ | |
| const listeners = []; | |
| GM_addValueChangeListener('list', (key, old_val, new_val, remote) => { | |
| listeners.forEach(l => debugging.callWithErrorHandling(l, null, [new_val])); | |
| }); | |
| /** | |
| * 将书籍添加到稍后再读列表 | |
| * @param {Book} book | |
| * @returns {boolean} 添加成功,还是已经在稍后再读中 | |
| */ | |
| function add(book) { | |
| /** @type {Book[]} */ | |
| const list = GM_getValue('list'); | |
| if (list.some(b => b.aid === book.aid)) { return false; } | |
| list.push(book); | |
| GM_setValue('list', list); | |
| return true; | |
| } | |
| /** | |
| * 从稍后再读中移除一本书 | |
| * @param {number} aid | |
| * @returns {Book | null} 如果移除成功,返回这本书;如果指定书不存在,返回null | |
| */ | |
| function remove(aid) { | |
| /** @type {Book[]} */ | |
| const list = GM_getValue('list'); | |
| const index = list.findIndex(b => b.aid === aid); | |
| if (index < 0) { return null; } | |
| const book = list.splice(index, 1)[0]; | |
| GM_setValue('list', list); | |
| return book; | |
| } | |
| /** | |
| * 添加稍后列表值改变监听器 | |
| * @param {(val: Book[]) => any} listener | |
| */ | |
| function onChange(listener) { | |
| listeners.push(listener); | |
| } | |
| return { | |
| /** @type {Book[]} */ | |
| get list() { return GM_getValue('list'); }, | |
| set list(val) { return GM_setValue('list', val); }, | |
| add, remove, onChange, | |
| }; | |
| } | |
| }, | |
| bookpage: { | |
| desc: '书籍信息页添加稍后再读按钮', | |
| checkers: [{ | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/articleinfo.php' | |
| }], | |
| dependencies: 'core', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.bookpage.func>>} bookpage */ | |
| async func() { | |
| /** @type {sidepanel} */ | |
| const sidepanel = await require('sidepanel', true); | |
| /** @type {core} */ | |
| const core = pool.require('core'); | |
| sidepanel.registerButton({ | |
| id: 'readlater.add', | |
| icon: 'watch_later', | |
| label: CONST.Text.ReadLater.Add, | |
| index: 4, | |
| callback() { | |
| const aid = parseInt(new URLSearchParams(location.search).get('id') | |
| ?? location.pathname.match(/\/book\/(\d+)\.htm/)[1], 10); | |
| const name = $('#content > div:first-child > table:first-child > tbody > tr:first-child > td > table span > b').innerText.trim(); | |
| const cover = $('#content > div:first-child > table:nth-of-type(2) img').src; | |
| const success = core.add({ aid, name, cover }); | |
| const ReadLater = CONST.Text.ReadLater; | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: ReadLater.Added, | |
| caption: replaceText( | |
| success ? ReadLater.AddSuccess : ReadLater.AddDuplicate, | |
| { '{Name}': name } | |
| ), | |
| icon: success ? 'done' : 'question_mark', | |
| group: 'readlater.added' | |
| }); | |
| } | |
| }); | |
| } | |
| }, | |
| indexpage: { | |
| desc: '主页展示稍后再读', | |
| checkers: [{ | |
| type: 'path', | |
| value: '/index.php' | |
| }, { | |
| type: 'path', | |
| value: '/' | |
| }], | |
| detectDom: '.main.m_foot', | |
| dependencies: ['core'], | |
| async func() { | |
| /** @type {core} */ | |
| const core = pool.require('core'); | |
| // 创建稍后再读列表 | |
| const container = $$CrE({ | |
| tagName: 'div', | |
| classes: 'main' | |
| }); | |
| container.innerHTML = ` | |
| <div class="block"> | |
| <div class="blocktitle">${ CONST.Text.ReadLater.Title }</div> | |
| <div class="blockcontent"> | |
| <div style="height:155px;"> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| $('.main.m_foot').previousElementSibling.previousElementSibling.before(container); | |
| const books_container = $(container, '.blockcontent > div'); | |
| // 创建Sortable | |
| const sortable = new Sortable(books_container, { | |
| filter: '.plus-nosort', | |
| onUpdate(e) { | |
| const aidlist = sortable.toArray(); | |
| core.list = aidlist.map(aid => core.list.find(book => book.aid === parseInt(aid, 10))); | |
| }, | |
| }); | |
| // 创建列表内容 | |
| refreshList(); | |
| // 当列表更改时,重建列表 | |
| core.onChange(list => refreshList(list)); | |
| /** | |
| * 清空稍后再读列表并重建 | |
| * @param {Book[]} [list] | |
| */ | |
| function refreshList(list) { | |
| list = list ?? core.list; | |
| // 首先清空已有内容 | |
| [...books_container.children].forEach(elm => elm.remove()); | |
| // 重建 | |
| if (list.length) { | |
| // 如果稍后再读不为空,则为前十本书创建元素 | |
| // 之所以是前十本,是因为文库的这个列表只有展示十本的空间 | |
| list.filter((b, i) => i < 10).forEach(book => { | |
| const book_container = $$CrE({ | |
| tagName: 'div', | |
| attrs: { | |
| style: 'float: left;text-align:center;width: 95px; height:155px;overflow:hidden;', | |
| 'data-id': book.aid.toString(), | |
| }, | |
| styles: { position: 'relative' }, | |
| classes: 'plus-readlater-book', | |
| }); | |
| book_container.innerHTML = ` | |
| <a href="/book/${ book.aid }.htm" target="_blank"> | |
| <img src="${ book.cover }" border="0" width="90" height="127"></a> | |
| <br> | |
| <a href="/book/${ book.aid }.htm" target="_blank">${ book.name }</a> | |
| `; | |
| book_container.append($$CrE({ | |
| tagName: 'div', | |
| props: { | |
| innerHTML: `<i class="material-icons">close</i>`, | |
| }, | |
| classes: ['plus-remove-readlater'], | |
| listeners: [[ 'click', e => core.remove(book.aid) ]] | |
| })); | |
| addStyle(` | |
| .plus-remove-readlater { | |
| position: absolute; | |
| right: 0; | |
| top: 0; | |
| font-size: 1.5em; | |
| color: var(--p-primary); | |
| border: 1px dashed var(--p-primary); | |
| padding: 0.1em; | |
| cursor: pointer; | |
| background: rgba(255, 255, 255, 0.5); | |
| display: none; | |
| } | |
| :is(body.mobile, .plus-readlater-book:hover) .plus-remove-readlater { | |
| display: block; | |
| } | |
| .plus-remove-readlater:hover { | |
| background: rgba(255, 255, 255, 0.8); | |
| } | |
| `, 'readlater-style'); | |
| mousetip.set($(book_container, 'a:first-child'), book.name); | |
| books_container.append(book_container); | |
| }); | |
| } else { | |
| // 如果稍后再读为空,展示提示 | |
| books_container.append($$CrE({ | |
| tagName: 'div', | |
| props: { | |
| innerText: CONST.Text.ReadLater.EmptyListPlaceholder | |
| }, | |
| classes: ['plus-nosort', 'text-grey-7'], | |
| styles: { | |
| width: '100%', | |
| height: '100%', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| fontSize: '1.5em', | |
| } | |
| })); | |
| } | |
| } | |
| } | |
| } | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { | |
| GM_setValue, GM_getValue, GM_addValueChangeListener | |
| }); | |
| await promise; | |
| return { | |
| /** @type {core} */ | |
| core: pool.require('core'), | |
| /** 用于导出JSDoc类型,无实际作用 */ | |
| _types: { | |
| /** @type {Book} */ | |
| Book: {}, | |
| } | |
| }; | |
| }, | |
| }, | |
| blockfolding: { | |
| desc: '主页板块折叠', | |
| checkers: [{ | |
| type: 'path', | |
| value: '/' | |
| }, { | |
| type: 'path', | |
| value: '/index.php' | |
| }], | |
| dependencies: ['utils'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** | |
| * 记录了折叠状态但不再出现在文档中的板块的记录 | |
| * @typedef {{title: string, count: number}} DisappearRecord | |
| */ | |
| GM_getValue = utils.defaultedGet({ | |
| /** @type {string[]} 折叠板块的标题列表 */ | |
| folds: [], | |
| /** @type {DisappearRecord[]} 在folds中但在页面中未出现的板块列表 */ | |
| unused: [], | |
| }, GM_getValue); | |
| // 应用折叠到文档 | |
| detectDom({ | |
| selector: '.block', | |
| /** @param {HTMLDivElement} block */ | |
| callback(block) { | |
| block.matches('[class*="q-"]:not(body) *') || initBlock(block); | |
| } | |
| }); | |
| // 当存储改变时,同步改变文档折叠状态 | |
| GM_addValueChangeListener('folds', (key, old_val, new_val, remote) => { | |
| [...$All('.block')].forEach(block => applyFoldStatus(block)); | |
| }); | |
| // 清理已消失的板块的折叠状态 | |
| $AEL(window, 'load', e => { | |
| const folds = GM_getValue('folds'); | |
| const titles = [...$All('.block')].filter(block => !block.matches('[class*="q-"]:not(body) *')).map(block => getTitle(block)); | |
| let modified = false; | |
| // 记录在folds中、但最终未出现在文档中的板块,记录到unused中 | |
| // 当在unused中记录次数达到一定值时,将其从folds和unused中移除 | |
| folds.filter(t => !titles.includes(t)).forEach(title => { | |
| /** @type {DisappearRecord[]} */ | |
| const unused = GM_getValue('unused'); | |
| const record = unused.find(r => r.title === title) ?? { | |
| title, count: 0 | |
| }; | |
| record.count++; | |
| if (record.count >= CONST.Internal.RemoveBlockFoldingCount) { | |
| // 达到清除标准,从unused和folds中移除此板块和记录 | |
| modified = true; | |
| folds.splice(folds.indexOf(record.title), 1); | |
| unused.includes(record) && unused.splice(unused.indexOf(record), 1); | |
| } else { | |
| // 未达到清除标准,仅修改记录次数 | |
| !unused.includes(record) && unused.push(record); | |
| } | |
| GM_setValue('unused', unused); | |
| }); | |
| modified && GM_setValue('folds', folds); | |
| // 在unused中有记录,但本次观察到出现的板块,清除在unused中的记录 | |
| /** @type {DisappearRecord[]} */ | |
| const unused = GM_getValue('unused'); | |
| modified = false; | |
| unused.filter(r => titles.includes(r.title)).forEach(record => { | |
| unused.splice(unused.indexOf(record), 1); | |
| modified = true; | |
| }); | |
| modified && GM_setValue('unused', unused); | |
| }); | |
| // 样式 | |
| addStyle(` | |
| .plus-folded .blockcontent { | |
| display: none; | |
| } | |
| .blocktitle .foldbtn { | |
| display: inline; | |
| } | |
| .plus-folded .blocktitle .foldbtn { | |
| display: none; | |
| } | |
| .blocktitle .unfoldbtn { | |
| display: none; | |
| } | |
| .plus-folded .blocktitle .unfoldbtn { | |
| display: inline; | |
| } | |
| .foldbtn-group { | |
| float: right; | |
| height: 100%; | |
| display: flex; | |
| flex-direction: row; | |
| align-items: center; | |
| cursor: pointer; | |
| margin-right: 10px; | |
| width: 0; | |
| position: relative; | |
| overflow: visible; | |
| background: transparent; | |
| } | |
| .foldbtn-group * { | |
| position: absolute; | |
| right: 0; | |
| text-align: right; | |
| white-space: nowrap; | |
| } | |
| `); | |
| /** | |
| * 初始化指定板块,添加折叠/展开按钮,一次性应用存储的折叠/展开状态 | |
| * @param {HTMLDivElement} block | |
| */ | |
| function initBlock(block) { | |
| // 添加折叠/展开按钮 | |
| const button = $$CrE({ | |
| tagName: 'span', | |
| classes: 'foldbtn-group' | |
| }); | |
| button.append( | |
| $$CrE({ | |
| tagName: 'span', | |
| props: { | |
| innerText: CONST.Text.BlockFolding.Fold, | |
| }, | |
| classes: 'foldbtn', | |
| listeners: [['click', e => setFold(block, true)]] | |
| }), | |
| $$CrE({ | |
| tagName: 'span', | |
| props: { | |
| innerText: CONST.Text.BlockFolding.UnFold, | |
| }, | |
| classes: 'unfoldbtn', | |
| listeners: [['click', e => setFold(block, false)]] | |
| }), | |
| ); | |
| $(block, '.blocktitle').append(button); | |
| // 应用存储的折叠/展开状态 | |
| applyFoldStatus(block); | |
| } | |
| /** | |
| * 将存储的折叠状态应用到指定的板块DOM中 | |
| * @param {HTMLDivElement} block | |
| */ | |
| function applyFoldStatus(block) { | |
| const title = getTitle(block); | |
| const folded = GM_getValue('folds').includes(title); | |
| folded ? fold(block) : unfold(block); | |
| } | |
| /** | |
| * 将一个板块DOM置于折叠状态 | |
| * @param {HTMLDivElement} block | |
| */ | |
| function fold(block) { | |
| block.classList.add('plus-folded'); | |
| } | |
| /** | |
| * 将一个板块DOM置于展开(非折叠)状态 | |
| * @param {HTMLDivElement} block | |
| */ | |
| function unfold(block) { | |
| block.classList.remove('plus-folded'); | |
| } | |
| /** | |
| * 设置一个板块的折叠/展开状态到存储 | |
| * @param {HTMLDivElement} block | |
| * @param {boolean} fold | |
| */ | |
| function setFold(block, fold) { | |
| const title = getTitle(block); | |
| const folds = GM_getValue('folds'); | |
| fold ? | |
| (folds.includes(title) || folds.push(title)) : | |
| (folds.includes(title) && folds.splice(folds.indexOf(title), 1)); | |
| GM_setValue('folds', folds); | |
| } | |
| function getTitle(block) { | |
| const blocktitle = $(block, '.blocktitle').cloneNode(true); | |
| $(blocktitle, '.foldbtn-group')?.remove(); | |
| return blocktitle.innerText.trim(); | |
| } | |
| }, | |
| }, | |
| announcements: { | |
| desc: '在首页等位置插入脚本公告信息等', | |
| checkers: [{ | |
| type: 'path', | |
| value: '/' | |
| },{ | |
| type: 'path', | |
| value: '/index.php' | |
| }], | |
| detectDom: '.main.m_foot', | |
| async func() { | |
| const block = $('#centers > .block:first-child'); | |
| const blockcontent = $(block, '.blockcontent'); | |
| blockcontent.append( | |
| $CrE('br'), | |
| $$CrE({ | |
| tagName: 'span', | |
| props: { | |
| innerText: CONST.Text.Announcements.Running, | |
| }, | |
| styles: { | |
| color: '#6f9ff1' | |
| }, | |
| } | |
| )); | |
| } | |
| }, | |
| downloader: { | |
| desc: '多功能下载器', | |
| dependencies: ['utils', 'api'], | |
| checkers: [{ | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/articleinfo.php' | |
| }, { | |
| type: 'regpath', | |
| value: /\/novel\/\d+\/\d+\/index.html?/ | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/reader.php' | |
| }], | |
| /** @typedef {Awaited<ReturnType<typeof functions.downloader.func>>} downloader */ | |
| async func() { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {api} */ | |
| const api = require('api'); | |
| const pool_funcs = { | |
| core: { | |
| desc: '下载器核心:下载器界面、功能', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.core.func>>} core */ | |
| async func() { | |
| const Options = CONST.Text.Downloader.Options; | |
| const DownloadOptions = { | |
| format: { | |
| type: 'select', | |
| label: Options.Format.Title, | |
| options: [{ | |
| label: Options.Format.txt, | |
| value: 'txt', | |
| }, { | |
| label: Options.Format.txtfull, | |
| value: 'txtfull', | |
| },{ | |
| label: Options.Format.epub, | |
| value: 'epub', | |
| }, { | |
| label: Options.Format.image, | |
| value: 'image', | |
| }], | |
| default: 'epub', | |
| }, | |
| encoding: { | |
| type: 'select', | |
| label: Options.Encoding.Title, | |
| caption: Options.Encoding.Caption, | |
| options: [{ | |
| label: Options.Encoding.gbk, | |
| value: 'gbk', | |
| }, { | |
| label: Options.Encoding.utf8, | |
| value: 'utf-8', | |
| }], | |
| default: 'utf-8' | |
| }, | |
| }; | |
| /** | |
| * @typedef {Object} NovelInfo | |
| * @property {string} intro | |
| * @property {NovelMeta} meta | |
| * @property {NovelVolume[]} volumes | |
| * @property {string} cover | |
| */ | |
| /** | |
| * @typedef {Object} NovelMeta | |
| * @property {{value: string, aid: number}} Title | |
| * @property {string} Author | |
| * @property {number} DayHitsCount | |
| * @property {number} TotalHitsCount | |
| * @property {number} PushCount | |
| * @property {number} FavCount | |
| * @property {{value: string, sid: number}} PressId | |
| * @property {string} BookStatus | |
| * @property {number} BookLength | |
| * @property {string} LastUpdate | |
| * @property {string} Tags | |
| * @property {{value: string, cid: number}} LatestSection | |
| */ | |
| /** | |
| * @typedef {Object} NovelVolume | |
| * @property {string} name | |
| * @property {number} vid | |
| * @property {NovelChapter[]} chapters | |
| */ | |
| /** | |
| * @typedef {Object} NovelChapter | |
| * @property {string} name | |
| * @property {number} cid | |
| */ | |
| /** | |
| * @callback DownloadCallback | |
| * @param {Object} detail | |
| * @param {number} detail.aid | |
| * @param {NovelInfo} detail.info | |
| * @param {Record<string, any>} detail.options | |
| * @param {number[]} detail.chapters | |
| * @returns {any} | |
| */ | |
| const pool_funcs = { | |
| gui: { | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.gui.func>>} gui */ | |
| async func() { | |
| const container = $CrE('div'); | |
| const UI = CONST.Text.Downloader.UI; | |
| container.innerHTML = ` | |
| <q-dialog v-model="visible" full-width full-height class="plus-downloader"> | |
| <q-layout container view="hHh lpR fFf"> | |
| <q-header bordered class="bg-primary text-white" height-hint="98"> | |
| <q-toolbar> | |
| <q-toolbar-title> | |
| <q-icon name="book" class="q-px-sm"></q-icon> | |
| ${ CONST.Text.Downloader.Title } | |
| </q-toolbar-title> | |
| <q-btn icon="close" v-close-popup flat></q-btn> | |
| </q-toolbar> | |
| </q-header> | |
| <q-page-container> | |
| <q-page> | |
| <q-card square class="downloader-container q-pa-md"> | |
| <!-- 小屏幕上下拆分,大屏幕左右拆分 --> | |
| <div :class="{ row: horizontal }" class="text-body2 scroll" style="height: 100%;"> | |
| <!-- 书籍信息和下载选项 --> | |
| <div class="col"> | |
| <!-- 上半部分 书籍信息 --> | |
| <div class="row"> | |
| <!-- 左侧封面图 --> | |
| <div class="col-3 q-pa-md"> | |
| <q-skeleton v-if="loading" type="rect" width="100%" height="15em"></q-skeleton> | |
| <q-img v-else :src="info.cover"></q-img> | |
| </div> | |
| <!-- 右侧书籍信息 --> | |
| <div class="col-9 q-pa-md"> | |
| <div v-if="loading"> | |
| <q-skeleton type="rect" class="text-h5 q-mb-md" width="10em" height="1.2em"></q-skeleton> | |
| <q-skeleton type="rect" width="100%" height="12em"></q-skeleton> | |
| </div> | |
| <div v-else> | |
| <div class="text-h5 q-mb-md">{{ info.meta.Title.value }}</div> | |
| <div class="q-my-sm"><span class="text-weight-bold">${ UI.Author }</span>{{ info.meta.Author }}</div> | |
| <div class="q-my-sm"><span class="text-weight-bold">${ UI.BookStatus }</span>{{ info.meta.BookStatus }}</div> | |
| <div class="q-my-sm"><span class="text-weight-bold">${ UI.LastUpdate }</span>{{ info.meta.LastUpdate }}</div> | |
| <div class="q-my-sm"><span class="text-weight-bold">${ UI.Tags }</span>{{ info.meta.Tags }}</div> | |
| <div class="q-my-sm"><span class="text-weight-bold">${ UI.Intro }</span>{{ info.intro }}</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 下半部分 下载选项 --> | |
| <div> | |
| <q-list> | |
| <q-item tag="label" v-for="(option, key) in options" class="row"> | |
| <q-item-section class="col-9"> | |
| <q-item-label>{{ option.label }}</q-item-label> | |
| <q-item-label caption v-if="option.caption">{{ option.caption }}</q-item-label> | |
| </q-item-section> | |
| <q-item-section side class="col-3"> | |
| <!-- 根据不同option类型创建不同的表单元素 --> | |
| <div v-if="option.type === 'boolean'"> | |
| <q-toggle | |
| color="primary" | |
| v-model="option_vals[key]" | |
| > | |
| </div> | |
| <div v-if="option.type === 'select'" style="width: 100%;"> | |
| <q-select | |
| :options="option.options" | |
| v-model="option_vals[key]" | |
| ></q-select> | |
| </div> | |
| <div v-if="option.type === 'string'" style="width: 100%;"> | |
| <q-input | |
| v-model="option_vals[key]" | |
| ></q-input> | |
| </div> | |
| </q-item-section> | |
| </q-item> | |
| </q-list> | |
| </div> | |
| </div> | |
| <!-- 下载内容范围选择器 --> | |
| <div class="col q-pa-md" :class="{ scroll: horizontal }" :style="{ height: horizontal ? '100%' : '' }"> | |
| <div class="text-h5 q-pb-md">${ UI.ContentSelectorTitle }</div> | |
| <q-skeleton v-if="loading" type="rect" width="100%" height="70%"></q-skeleton> | |
| <q-tree v-else | |
| :nodes="tree" | |
| node-key="id" | |
| tick-strategy="leaf" | |
| v-model:ticked="ticked" | |
| ></q-tree> | |
| </div> | |
| </div> | |
| </q-card> | |
| </q-page> | |
| </q-page-container> | |
| <q-footer bordered class="text-lightdark bg-lightdark"> | |
| <q-toolbar> | |
| <q-toolbar-title> | |
| <!-- 下载进度显示 --> | |
| <span class="text-body2"> | |
| <span v-if="downloading"> | |
| <span class="q-px-sm">${ replaceText( | |
| UI.Progress.Global, | |
| { | |
| '{Total}': '{{ progress.total }}', | |
| '{CurStep}': '{{ Math.min(progress.total, progress.finished + 1) }}', | |
| '{Name}': '{{ sub_progress.name ?? "" }}', | |
| } | |
| ) }</span> | |
| <span class="q-px-sm">${ replaceText( | |
| UI.Progress.Sub, | |
| { | |
| '{Total}': '{{ sub_progress.total }}', | |
| '{CurStep}': '{{ Math.min(sub_progress.total, sub_progress.finished + 1) }}', | |
| } | |
| ) }</span> | |
| </span> | |
| <span v-else>{{ loading ? "${ UI.Progress.Loading }" : "${ UI.Progress.Ready }" }}</span> | |
| </span> | |
| </q-toolbar-title> | |
| <q-skeleton v-if="loading" type="QBtn"></q-skeleton> | |
| <q-btn v-else icon="download" :loading="downloading" :percentage="download_percentage" label="${ UI.DownloadButton }" @click="submit" flat></q-btn> | |
| </q-toolbar> | |
| </q-footer> | |
| </q-layout> | |
| </q-dialog> | |
| `; | |
| document.body.append(container); | |
| addStyle(` | |
| .plus-downloader .downloader-container { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| `); | |
| let instance; | |
| const app = Vue.createApp({ | |
| data() { | |
| return { | |
| visible: false, | |
| aid: 0, | |
| // 正在加载状态(加载时显示占位UI) | |
| loading: false, | |
| // 正在下载状态(下载时显示下载状态UI) | |
| downloading: false, | |
| // 是否已获取到完整api信息 | |
| api_loaded: false, | |
| // 存储api原始信息 | |
| api: { | |
| full_intro: null, | |
| full_meta: null, | |
| novel_index: null, | |
| }, | |
| // 下载选项 | |
| options: {}, | |
| // 选项数据 | |
| option_vals: {}, | |
| // 用户选择下载的内容 | |
| ticked: [], | |
| // 下载按钮回调 | |
| /** @type {DownloadCallback} */ | |
| callback: (...args) => console.log(args), | |
| // 下载进度管理器 | |
| download_manager: null, | |
| // 下载进度 | |
| progress: { | |
| finished: 0, | |
| total: 0, | |
| }, | |
| // 次级下载进度管理器 | |
| sub_manager: null, | |
| // 次级下载进度 | |
| sub_progress: { | |
| finished: 0, | |
| total: 0, | |
| name: null, | |
| } | |
| } | |
| }, | |
| computed: { | |
| /** | |
| * 是否为大屏幕,大屏幕横向布局,小屏幕纵向布局 | |
| * @type {boolean} | |
| */ | |
| horizontal() { | |
| return Quasar.Screen.gt.sm; | |
| }, | |
| /** | |
| * 从api原始信息解析为纯信息数据对象 | |
| * @type {NovelInfo} | |
| */ | |
| info() { | |
| const { full_intro, full_meta, novel_index, cover } = this.api; | |
| /** @type {NovelInfo} */ | |
| const info = {}; | |
| info.intro = full_intro; | |
| info.meta = [...$All(full_meta, 'data')].reduce((meta, data) => { | |
| const attrs = {}; | |
| // 获取主要值 | |
| const name = data.getAttribute('name'); | |
| const value = data.getAttribute('value') ?? data.firstChild.nodeValue; | |
| // 获取次要值 | |
| const cloned_data = data.cloneNode(true); | |
| cloned_data.removeAttribute('name'); | |
| cloned_data.removeAttribute('value'); | |
| const attr_names = cloned_data.getAttributeNames(); | |
| // 根据次要值是否存在决定如何合并到总meta数据对象中 | |
| if (attr_names.length) { | |
| // 次要值存在:主要值作为"value"属性值,次要值作为其他属性,整体attr对象作为一个属性合并到meta数据对象中 | |
| attrs.value = value; | |
| for (let attr_name of attr_names) { | |
| let attr_val = data.getAttribute(attr_name); | |
| attr_val = /^\d+$/.test(attr_val) ? parseInt(attr_val, 10) : attr_val; | |
| attrs[attr_name] = attr_val; | |
| } | |
| return Object.assign(meta, { [name]: attrs }); | |
| } else { | |
| // 次要值不存在,只有主要值:name: 主要值 直接作为一个属性合并到meta数据对象中 | |
| attrs[name] = value; | |
| return Object.assign(meta, attrs); | |
| } | |
| }, {}); | |
| info.volumes = [...$All(novel_index, 'volume')].map(volume => { | |
| return { | |
| name: volume.firstChild.nodeValue, | |
| vid: parseInt(volume.getAttribute('vid'), 10), | |
| chapters: [...$All(volume, 'chapter')].map(chapter => { | |
| return { | |
| name: chapter.firstChild.nodeValue, | |
| cid: parseInt(chapter.getAttribute('cid'), 10), | |
| }; | |
| }), | |
| }; | |
| }); | |
| info.cover = `http://img.wenku8.com/image/${ Math.floor(this.aid / 1000) }/${ this.aid }/${ this.aid }s.jpg`; | |
| return info; | |
| }, | |
| tree() { | |
| // 注意:QTree的节点id要求全局唯一(而不仅仅是同层级唯一),这里直接使用了 | |
| // vid和cid作为QTree的id,是因为已知vid、cid是全局唯一的。若vid、cid并非 | |
| // 全局唯一,就需要自行创建适用于QTree的id并做好与章节、分卷之间的映射 | |
| return this.api_loaded ? [{ | |
| id: 'root', | |
| label: UI.ContentSelectorRoot, | |
| children: this.info.volumes.map( | |
| volume => ({ | |
| id: volume.vid, | |
| label: volume.name, | |
| children: volume.chapters.map(chapter => ({ | |
| id: chapter.cid, | |
| label: chapter.name, | |
| })) | |
| }) | |
| ), | |
| }] : []; | |
| }, | |
| download_percentage() { | |
| return (this.progress.finished / this.progress.total) * 100; | |
| }, | |
| }, | |
| watch: { | |
| // 当options改变时,重置option_vals为各option.default | |
| options: { | |
| handler(val, old_val) { | |
| this.option_vals = Object.entries(Vue.toRaw(val)).reduce( | |
| (vals, [key, option]) => | |
| Object.assign(vals, { [key]: option.options.find(o => o.value === option.default) }), | |
| {} | |
| ); | |
| }, | |
| deep: true, | |
| }, | |
| // 自动绑定下载管理器进度与当前app下载进度 | |
| download_manager: { | |
| handler(new_manager, old_manager) { | |
| if (!new_manager) { return; } | |
| const that = this; | |
| // 同步大进度 | |
| const progress = this.progress; | |
| const sync = manager => { | |
| progress.finished = manager.finished; | |
| progress.total = manager.steps; | |
| }; | |
| $AEL(new_manager, 'progress', e => sync(new_manager)); | |
| // 防止下载器在首次更新进度时还没有添加进度同步监听器,这里手动同步一次 | |
| this.download_manager && sync(this.download_manager) | |
| // 同步小进度 | |
| const sub_progress = this.sub_progress; | |
| const linkSubManager = sub_manager => { | |
| that.sub_manager = sub_manager; | |
| sub_progress.name = sub_manager.info; | |
| $AEL(sub_manager, 'progress', e => { | |
| sub_progress.finished = sub_manager.finished; | |
| sub_progress.total = sub_manager.steps; | |
| }); | |
| }; | |
| $AEL(new_manager, 'sub', e => { | |
| const sub_manager = new_manager.children[new_manager.children.length-1]; | |
| linkSubManager(sub_manager); | |
| }); | |
| // 防止下载器在首次生成子进度管理器的时候还没有添加小进度同步监听器,这里手动同步一次 | |
| if (new_manager.children.length) { | |
| const sub_manager = new_manager.children[new_manager.children.length-1]; | |
| linkSubManager(sub_manager); | |
| } | |
| // 有关大小进度:实际下载实现中,所有下载器均应按照以下标准: | |
| // - 整体下载进度分N步,称为 大步骤、大进度 | |
| // - 每个大进度内部分M步,称为 小步骤、小进度 | |
| // - 只有当一个大步骤内部的全部小步骤都完成时,这个大步骤才会完成,此时大进度++,刚刚完成的这个大步骤内部的小进度应为100% | |
| // - 大进度和小进度分别用一个ProgressManager和它的一个sub manager表示和管理 | |
| // 因此,全局只有一个大进度对应的ProgressManager,统一时刻只有一个活跃的sub manager | |
| // 故不用担心上一大步骤的下属sub manager突然更新并对sub_progress写入脏数据,因为所有之前大步骤的sub_manager都应时100%进度且不再活跃 | |
| }, | |
| immediate: true, | |
| }, | |
| // 当章节列表更新时,自动选中全部章节 | |
| tree: { | |
| handler(new_tree, old_tree) { | |
| if (!this.api_loaded) { return; } | |
| const root = new_tree[0]; | |
| for (const volume of root.children) { | |
| for (const chapter of volume.children) { | |
| this.ticked.push(chapter.id); | |
| } | |
| } | |
| }, | |
| immediate: true, | |
| } | |
| }, | |
| methods: { | |
| /** | |
| * 从文库服务器获取有关当前书籍的全部下载器所需信息,填充到this.api中 | |
| * 获取时将UI置为加载中状态 | |
| */ | |
| async request() { | |
| this.loading = true; | |
| const [aid, lang] = [this.aid, utils.getLanguage()]; | |
| [ | |
| this.api.full_intro, | |
| this.api.full_meta, | |
| this.api.novel_index, | |
| ] = await Promise.all([ | |
| api.getNovelFullIntro({ aid, lang }), | |
| api.getNovelFullMeta({ aid, lang }), | |
| api.getNovelIndex({ aid, lang }), | |
| ]); | |
| this.loading = false; | |
| this.api_loaded = true; | |
| }, | |
| resetProgress() { | |
| this.progress = { | |
| finished: 0, | |
| total: 0, | |
| }; | |
| this.sub_progress = { | |
| finished: 0, | |
| total: 0, | |
| name: null, | |
| }; | |
| this.download_manager = null; | |
| this.sub_manager = null; | |
| }, | |
| async submit() { | |
| const aid = this.aid; | |
| const info = structuredClone(Vue.toRaw(this.info)); | |
| const chapters = Array.from(Vue.toRaw(this.ticked)); | |
| const options = Object.entries(Vue.toRaw(this.option_vals)) | |
| .reduce((options, [key, val]) => | |
| Object.assign(options, { [key]: val.value }), {}); | |
| const callback = this.callback ?? function() {}; | |
| if (chapters.length) { | |
| this.downloading = true; | |
| this.resetProgress(); | |
| await Promise.resolve(callback({ aid, info, options, chapters })); | |
| this.downloading = false; | |
| } else { | |
| Quasar.Notify.create({ | |
| type: 'error', | |
| message: CONST.Text.Downloader.UI.NoContentSelected, | |
| group: 'downloader.core.gui.no-chapters-selected', | |
| }); | |
| } | |
| }, | |
| }, | |
| mounted() { | |
| instance = this; | |
| }, | |
| }); | |
| app.use(Quasar); | |
| app.mount(container); | |
| /** | |
| * 根据提供的书籍aid,初始化并展示下载器gui | |
| * @param {number} aid | |
| * @param {DownloadCallback} [callback] | |
| */ | |
| async function show(aid, callback) { | |
| instance.aid = aid; | |
| callback && (instance.callback = callback); | |
| instance.options = DownloadOptions; | |
| instance.request(); | |
| instance.visible = true; | |
| } | |
| /** | |
| * 隐藏下载器gui | |
| */ | |
| function hide() { | |
| instance.visible = false; | |
| } | |
| return { | |
| get download_progress() { return instance.download_manager; }, | |
| set download_progress(manager) { instance.download_manager = manager; }, | |
| show, hide, | |
| }; | |
| } | |
| }, | |
| downloader: { | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.downloader.func>>} downloader */ | |
| async func() { | |
| // 每种下载格式独立实现一个子功能函数,提供download接口 | |
| /** | |
| * 标准下载接口 | |
| * @callback DownloadFunction | |
| * @param {Object} options | |
| * @param {number} options.aid - 书籍id | |
| * @param {NovelInfo} options.info - 书籍信息 | |
| * @param {number[]} options.chapters - 需要下载的章节列表 | |
| * @param {string} [options.encoding='utf-8'] - 使用的编码(如果支持) | |
| * @returns {{ blob_promise: Promise<Blob>, manager: InstanceType<typeof utils.ProgressManager>, filename: string }} | |
| */ | |
| const pool_funcs = { | |
| txt: { | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.txt.func>>} txt */ | |
| func() { | |
| /** | |
| * 下载为txt文件 | |
| * @type {DownloadFunction} | |
| */ | |
| async function download({ aid, info, chapters, encoding='utf-8' }) { | |
| // 进度管理器 | |
| const manager = new utils.ProgressManager(3); | |
| // 下载txt主流程 | |
| const blob_promise = new Promise(async (resolve, reject) => { | |
| // 下载章节内容 | |
| const manager_content = manager.sub(chapters.length, CONST.Text.Downloader.Steps.txt.NovelContent); | |
| const lang = utils.getLanguage(); | |
| const contents = await manager.progress(Promise.all(chapters.map(async cid => | |
| await manager_content.progress(api.getNovelContent({ | |
| aid, cid, lang, | |
| })) | |
| ))); | |
| // 编码 | |
| const manager_encode = manager.sub(chapters.length, CONST.Text.Downloader.Steps.txt.EncodeText); | |
| const SupportedEncodings = ['gbk', 'big5']; | |
| const blobs = contents.map(content => { | |
| const buffer = SupportedEncodings.includes(encoding) ? | |
| $URL[encoding].encodeBuffer(content) : | |
| new TextEncoder().encode(content); | |
| const blob = new Blob([buffer], { type: 'text/plain' }); | |
| manager_encode.progress(); | |
| return blob; | |
| }); | |
| manager.progress(); | |
| // 合成zip文件 | |
| const manager_zip = manager.sub(100, CONST.Text.Downloader.Steps.txt.GenerateZIP); | |
| const zip = new JSZip(); | |
| blobs.forEach((blob, i) => { | |
| const cid = chapters[i]; | |
| const volume = info.volumes.find(v => v.chapters.some(c => c.cid === cid)); | |
| const chapter = volume.chapters.find(c => c.cid === cid); | |
| const folder = zip.folder(`${ volume.vid } - ${volume.name}`); | |
| folder.file(`${ chapter.cid } - ${ chapter.name }.txt`, blob); | |
| }); | |
| const blob = await manager.progress(zip.generateAsync( | |
| { type: 'blob' }, | |
| metadata => manager_zip.progress(null, Math.round(metadata.percent)) | |
| )); | |
| resolve(blob); | |
| }); | |
| return { | |
| blob_promise, | |
| manager, | |
| filename: `${aid} - ${info.meta.Title.value}.zip`, | |
| } | |
| } | |
| return { download }; | |
| } | |
| }, | |
| txtfull: { | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.txtfull.func>>} txtfull */ | |
| func() { | |
| /** | |
| * 下载为txt文件 | |
| * @type {DownloadFunction} | |
| */ | |
| async function download({ aid, info, chapters, encoding='utf-8' }) { | |
| // 进度管理器 | |
| const manager = new utils.ProgressManager(2); | |
| // 下载txt主流程 | |
| const blob_promise = new Promise(async (resolve, reject) => { | |
| // 下载章节内容 | |
| const manager_content = manager.sub(chapters.length, CONST.Text.Downloader.Steps.txt.NovelContent); | |
| const lang = utils.getLanguage(); | |
| const contents = await manager.progress(Promise.all(chapters.map(async cid => | |
| await manager_content.progress(api.getNovelContent({ | |
| aid, cid, lang, | |
| })) | |
| ))); | |
| // 拼接全本内容 | |
| const full_text = [ | |
| `<${ info.meta.Title.value }>\n`, | |
| ...contents.map((content, i) => { | |
| const cid = chapters[i]; | |
| const volume = info.volumes.find(v => v.chapters.some(c => c.cid === cid)); | |
| const chapter = volume.chapters.find(c => c.cid === cid); | |
| // api返回的章节内容会自带章节标题,为了格式和文库下载的txt全本保持一致,现去掉自带的标题再格式化添加 | |
| content = content.trimStart(); | |
| content.startsWith(volume.name) && (content = content.replace(volume.name, '')); | |
| content = content.trimStart(); | |
| content.startsWith(chapter.name) && (content = content.replace(chapter.name, '')); | |
| content = content.trimStart(); | |
| return `${ volume.name } ${ chapter.name }\n\n${ content }`; | |
| }), | |
| ].join('\n\n'); | |
| // 编码 | |
| const manager_encode = manager.sub(chapters.length, CONST.Text.Downloader.Steps.txt.EncodeText); | |
| const SupportedEncodings = ['gbk', 'big5']; | |
| const buffer = SupportedEncodings.includes(encoding) ? | |
| $URL[encoding].encodeBuffer(full_text) : | |
| new TextEncoder().encode(full_text); | |
| const blob = new Blob([buffer], { type: 'text/plain' }); | |
| manager_encode.progress(); | |
| manager.progress(); | |
| resolve(blob); | |
| }); | |
| return { | |
| blob_promise, | |
| manager, | |
| filename: `${aid} - ${info.meta.Title.value}.txt`, | |
| } | |
| } | |
| return { download }; | |
| } | |
| }, | |
| image: { | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.image.func>>} image */ | |
| func() { | |
| /** | |
| * 下载全部插图 | |
| * @type {DownloadFunction} | |
| */ | |
| async function download({ aid, info, chapters, encoding='utf-8' }) { | |
| const manager = new utils.ProgressManager(3); | |
| // 获取与合成图片zip文件主流程 | |
| const blob_promise = new Promise(async (resolve, reject) => { | |
| // 获取全部章节,解析插图 | |
| /** | |
| * @typedef {Object} ImageChapter | |
| * @property {string[]} urls | |
| * @property {number} cid | |
| * @property {string} title | |
| */ | |
| const manager_content = manager.sub(chapters.length, CONST.Text.Downloader.Steps.image.NovelContent); | |
| const lang = utils.getLanguage(); | |
| const image_chapters = await manager.progress(Promise.all(chapters.map(async cid => { | |
| const content = await api.getNovelContent({ aid, cid, lang }); | |
| const matches = content.matchAll(/<!--image-->([^<]+?)<!--image-->/g); | |
| const urls = [...matches].map(([full, url]) => url); | |
| const volume = info.volumes.find(volume => volume.chapters.some(chapter => chapter.cid === cid)); | |
| const chapter = volume.chapters.find(chapter => chapter.cid === cid); | |
| const title = chapter.name; | |
| /** @type {ImageChapter} */ | |
| const image_chapter = { cid, title, urls }; | |
| manager_content.progress(); | |
| return image_chapter; | |
| }))); | |
| // 获取全部插图并打包为ZIP | |
| const manager_image = manager.sub(image_chapters.length, CONST.Text.Downloader.Steps.image.DownloadImage); | |
| const zip = new JSZip(); | |
| await manager.progress(Promise.all(image_chapters.map(async image_chapter => { | |
| // 没有图片的章节就不创建文件夹了 | |
| if (!image_chapter.urls.length) { return; } | |
| // 为章节创建文件夹 | |
| const foldername = `${image_chapter.cid} - ${image_chapter.title}`; | |
| const folder = zip.folder(foldername); | |
| // 添加图片到文件夹中 | |
| const num_len = image_chapter.urls.length.toString().length; | |
| await Promise.all(image_chapter.urls.map(async (url, i) => { | |
| const path = new URL(url).pathname; | |
| const ext = path.includes('.') ? path.slice(path.lastIndexOf('.') + 1) : 'jpg'; | |
| const filename = `${ utils.zfill(`${i+1}`, num_len) }.${ ext }`; | |
| const blob = await utils.requestBlob(url); | |
| folder.file(filename, blob); | |
| })); | |
| manager_image.progress(); | |
| }))); | |
| // 生成blob文件 | |
| const manager_blob = manager.sub(100, CONST.Text.Downloader.Steps.image.GenerateZIP); | |
| const blob = await manager.progress(zip.generateAsync( | |
| { type: 'blob' }, | |
| metadata => manager_blob.progress(null, Math.round(metadata.percent)) | |
| )); | |
| resolve(blob); | |
| }); | |
| return { | |
| blob_promise, | |
| manager, | |
| filename: `${aid} - ${info.meta.Title.value}.zip`, | |
| } | |
| } | |
| return { download }; | |
| } | |
| }, | |
| epub: { | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.epub.func>>} epub */ | |
| func() { | |
| /** | |
| * @type {DownloadFunction} | |
| */ | |
| function download({ aid, info, chapters, encoding='utf-8' }) { | |
| const manager = new utils.ProgressManager(2); | |
| const blob_promise = new Promise(async (resolve, reject) => { | |
| // jEpub 实例 | |
| const epub = new jEpub(); | |
| epub.init({ | |
| i18n: 'en', | |
| title: info.meta.Title.value, | |
| author: info.meta.Author, | |
| publisher: info.meta.PressId.value, | |
| description: info.intro, | |
| tags: info.meta.Tags.split(/\s+/g) | |
| }); | |
| epub.date(new Date(info.meta.LastUpdate)); | |
| epub.notes(replaceText( | |
| CONST.Text.Downloader.Notes, { | |
| '{URL}': `https://${location.host}/book/${aid}.htm`, | |
| } | |
| )); | |
| /** | |
| * 用于记录分卷层级信息的Map | |
| * 内容为每一分卷所对应的全部章节在epub中的page的index数组 | |
| * @type {Map<NovelVolume, number[]>} | |
| */ | |
| const volume_map = new Map(); | |
| // 并发进行所有需要网络请求的工作 | |
| const manager_fetch = manager.sub(chapters.length + 1, CONST.Text.Downloader.Steps.epub.NovelContent); | |
| await manager.progress(Promise.all([ | |
| // 加载封面 | |
| (async function() { | |
| const blob = await utils.requestBlob(info.cover); | |
| epub.cover(blob); | |
| manager_fetch.progress(); | |
| }) (), | |
| // 加载章节内容 | |
| (async function() { | |
| // 先获取、整理章节内容 | |
| const epub_chapters = await Promise.all(chapters.map(async (cid, i) => { | |
| // 获取章节内容 | |
| const lang = utils.getLanguage(); | |
| const content = await api.getNovelContent({ aid, cid, lang }); | |
| let html_content = content; | |
| // 处理章节图片 | |
| const matches = [...html_content.matchAll(/<!--image-->([^<]+?)<!--image-->/g)]; | |
| const len = matches.length.toString().length; | |
| const chapter_index = utils.zfill(`${i + 1}`, chapters.length.toString().length); | |
| await Promise.all(matches.map(async ([full, url], i) => { | |
| const image_index = utils.zfill(`${i+1}`, len); | |
| const image_id = `ChapterImage-${ chapter_index }-${ image_index }`; | |
| html_content = html_content.replace(full, `<%= image[${ escJsStr(image_id) }] %>`); | |
| epub.image(await utils.requestBlob(url), image_id); | |
| })); | |
| // 整理文本内容 | |
| html_content = html_content.split(/[\r\n]+/g).map(line => `<p>${line}</p>`).join('\n'); | |
| // 整理返回epub信息 | |
| const volume = info.volumes.find(v => v.chapters.some(c => c.cid === cid)); | |
| const chapter = volume.chapters.find(c => c.cid === cid); | |
| manager_fetch.progress(); | |
| return { | |
| volume, chapter, | |
| title: chapter.name, | |
| content: html_content, | |
| }; | |
| })); | |
| // 最后再按顺序统一添加到epub | |
| // 同时记录分卷层级信息 | |
| epub_chapters.forEach((epub_chapter, index) => { | |
| // 添加章节到epub | |
| epub.add(epub_chapter.title, epub_chapter.content); | |
| // 记录分卷层级信息 | |
| const volume = epub_chapter.volume; | |
| volume_map.has(volume) || volume_map.set(volume, []); | |
| volume_map.get(volume).push(index); | |
| }); | |
| }) (), | |
| ])); | |
| // Hook epub的zip文件添加过程,以修改toc文件内部目录层级 | |
| const zip = epub._Zip; | |
| const add_file = zip.file.bind(zip); | |
| zip.file = function(path, content) { | |
| switch (path) { | |
| case 'toc.ncx': | |
| return ncx(); | |
| case 'OEBPS/table-of-contents.html': | |
| return html(); | |
| default: | |
| return add_file(...arguments); | |
| } | |
| function ncx() { | |
| // 解析为xml | |
| const xml = new DOMParser().parseFromString(content, 'application/xml'); | |
| // 按照分卷重构目录结构 | |
| volume_map.entries().forEach(([volume, indexes], volume_index) => { | |
| // 创建分卷层级的<navPoint> | |
| const first_page_src = $(xml, `#page-${indexes[0]} > content`).getAttribute('src'); | |
| const volume_nav = xml.createElement('navPoint'); | |
| volume_nav.id = `volume-${volume_index}`; | |
| volume_nav.innerHTML = ` | |
| <navLabel> | |
| <text>${ utils.htmlEncode(volume.name) }</text> | |
| </navLabel> | |
| <content src=${ escJsStr(first_page_src) }></content> | |
| `; | |
| $(xml, 'navMap').append(volume_nav); | |
| // 将该分卷所属所有章节的<navPoint>移动到分卷<navPoint>内 | |
| indexes.forEach(index => volume_nav.append($(xml, `#page-${index}`))); | |
| }); | |
| // 重新生成playOrder | |
| let playOrder = 0; | |
| const order_map = new Map(); | |
| for (const nav of $All(xml, 'navPoint')) { | |
| const src = $(nav, 'content').getAttribute('src'); | |
| order_map.has(src) || order_map.set(src, ++playOrder); | |
| nav.setAttribute('playOrder', (order_map.get(src)).toString()); | |
| } | |
| // 序列化为xml代码 | |
| let new_xml_code = new XMLSerializer().serializeToString(xml); | |
| // xml序列化会自动添加namespace信息,即xmlns="...",不符合epub规范,需要删掉 | |
| new_xml_code = new_xml_code.replaceAll(/navPoint xmlns="[^"]*"/g, 'navPoint'); | |
| // 添加到zip中 | |
| return add_file(path, new_xml_code); | |
| } | |
| function html() { | |
| // 解析为html文档 | |
| const doc = new DOMParser().parseFromString(content, 'text/html'); | |
| // 按照分卷重构目录结构 | |
| volume_map.entries().forEach(([volume, indexes], volume_index) => { | |
| const li = $$CrE({ | |
| tagName: 'li', | |
| classes: 'chaptertype-1', | |
| props: { innerHTML: volume.name }, | |
| }); | |
| const ul = $CrE('ul'); | |
| li.append(ul); | |
| $(doc, '#toc > ul').append(li); | |
| indexes.forEach(index => { | |
| const a = $(doc, `a[href="page-${index}.html"]`); | |
| const li = a.parentElement; | |
| li.classList.remove('chaptertype-1'); | |
| ul.append(li); | |
| }); | |
| }); | |
| // 序列化为html代码 | |
| const new_html_code = new XMLSerializer().serializeToString(doc); | |
| // 添加到zip中 | |
| return add_file(path, new_html_code); | |
| } | |
| } | |
| // 为epub生成blob | |
| const manager_blob = manager.sub(100, CONST.Text.Downloader.Steps.epub.GenerateEpub); | |
| const blob = await manager.progress(epub.generate( | |
| 'blob', | |
| metadata => manager_blob.progress(null, Math.round(metadata.percent)) | |
| )); | |
| resolve(blob); | |
| }); | |
| return { | |
| blob_promise, | |
| manager, | |
| filename: `${aid} - ${info.meta.Title.value}.epub`, | |
| } | |
| } | |
| return { download }; | |
| } | |
| }, | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { | |
| GM_setValue, GM_getValue, GM_addValueChangeListener | |
| }); | |
| await promise; | |
| /** @type {txt} */ | |
| const txt = pool.require('txt'); | |
| /** @type {txtfull} */ | |
| const txtfull = pool.require('txtfull'); | |
| /** @type {image} */ | |
| const image = pool.require('image'); | |
| /** @type {epub} */ | |
| const epub = pool.require('epub'); | |
| return { txt, txtfull, image, epub, }; | |
| } | |
| }, | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { | |
| GM_setValue, GM_getValue, GM_addValueChangeListener | |
| }); | |
| await promise; | |
| /** @type {gui} */ | |
| const gui = pool.require('gui'); | |
| /** @type {downloader} */ | |
| const downloader = pool.require('downloader'); | |
| /** | |
| * 为指定书籍展示下载器 | |
| * @param {number} aid | |
| */ | |
| function show(aid) { | |
| gui.show(aid, async ({ aid, info, chapters, options }) => { | |
| if (downloader[options.format]) { | |
| const { blob_promise, manager, filename } = await downloader[options.format].download({ | |
| aid, | |
| info, | |
| chapters, | |
| encoding: options.encoding, | |
| }); | |
| gui.download_progress = manager; | |
| const blob = await blob_promise; | |
| const url = URL.createObjectURL(blob); | |
| dl_browser(url, filename); | |
| setTimeout(() => URL.revokeObjectURL(url)); | |
| } else { | |
| console.log(aid, info, chapters, options); | |
| } | |
| }); | |
| } | |
| return { | |
| gui, downloader, | |
| show, | |
| }; | |
| } | |
| }, | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { | |
| GM_setValue, GM_getValue, GM_addValueChangeListener | |
| }); | |
| await promise; | |
| /** @type {core} */ | |
| const core = pool.require('core'); | |
| require('sidepanel', true).then( | |
| /** @param {sidepanel} sidepanel */ | |
| sidepanel => sidepanel.registerButton({ | |
| id: 'downloader.show', | |
| label: CONST.Text.Downloader.SideButton, | |
| icon: 'download', | |
| index: 2, | |
| async callback() { | |
| const aid = parseInt( | |
| new URLSearchParams(location.search).get('aid') ?? | |
| new URLSearchParams(location.search).get('id') ?? | |
| location.href.match(/book\/(\d+)\.htm/)?.[1] ?? | |
| location.href.match(/novel\/\d+\/(\d+)\//)?.[1], | |
| 10); | |
| core.show(aid); | |
| } | |
| }) | |
| ); | |
| } | |
| }, | |
| autovote: { | |
| desc: '每日自动推书', | |
| dependencies: ['utils', 'debugging', 'logger', 'configs', 'storageupdater'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_listValues', 'GM_deleteValue', 'GM_addValueChangeListener'], | |
| async func(GM_setValue, GM_getValue, GM_listValues, GM_deleteValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {debugging} */ | |
| const debugging = require('debugging'); | |
| /** @type {logger} */ | |
| const logger = require('logger'); | |
| /** @type {configs} */ | |
| const configs = require('configs'); | |
| /** @type {storageupdater} */ | |
| const storageupdater = require('storageupdater'); | |
| /** @type {component} */ | |
| const component = require('component'); | |
| /** @type {api} */ | |
| const api = require('api'); | |
| /** | |
| * @typedef {Object} Book | |
| * @property {number} aid | |
| * @property {string} name | |
| * @property {string} cover - 封面url | |
| * @property {number} votes - 每日推书票数 | |
| * @property {number} time_added - 添加到自动推书列表的时间 | |
| * @property {number} voted - 累计自动推书票数 | |
| */ | |
| /** | |
| * @typedef {Object} VoteRecord | |
| * @property {number} last_voted - 上一次执行自动推书的时间 | |
| * @property {Record<string, number>} vote_status - 上一次执行自动推书时的推书进度 | |
| */ | |
| /** | |
| * 代表一个文库帐号下的自动推书配置 | |
| * @typedef {Object} AccountConfig | |
| * @property {Book[]} list - 自动推书配置 | |
| * @property {VoteRecord} record - 上一次执行自动推书的记录 | |
| * @property {boolean} enabled - 是否对此帐号启用自动推书 | |
| */ | |
| GM_getValue = utils.defaultedGet({ | |
| /** @type {Record<string, AccountConfig>} */ | |
| accounts: {}, | |
| /** @type {boolean} */ | |
| enabled: true, | |
| 'config_version': 1, | |
| }, GM_getValue); | |
| storageupdater.update([ | |
| function v0_v1(config) { | |
| // v0版本的所有帐号均共用同一套自动推书配置,v1版本将自动推书配置的推书配置、推书记录分帐号存储 | |
| // 现有推书配置复制到当前帐号下 | |
| /** @type {AccountConfig} */ | |
| const account = { | |
| list: config.list ?? [], | |
| record: config.record ?? { | |
| last_voted: 0, | |
| vote_status: {}, | |
| }, | |
| enabled: true, | |
| }; | |
| const uid = utils.getUserID(); | |
| uid !== null && (config.accounts = { [ uid.toString() ]: account }); | |
| delete config.list; | |
| delete config.record; | |
| return config; | |
| }, | |
| ], { GM_setValue, GM_getValue, GM_listValues, GM_deleteValue }); | |
| const Settings = CONST.Text.Autovote.Settings; | |
| configs.registerConfig('autovote', { | |
| GM_addValueChangeListener, | |
| items: [{ | |
| type: 'boolean', | |
| label: Settings.Enabled, | |
| caption: Settings.EnabledCaption, | |
| key: 'enabled', | |
| reload: true, | |
| get() { return GM_getValue('enabled'); }, | |
| set(val) { GM_setValue('enabled', val); }, | |
| }, { | |
| type: 'button', | |
| label: Settings.Configuration, | |
| button_icon: 'edit_note', | |
| button_label: Settings.Configure, | |
| async callback() { | |
| /** @type {gui} */ | |
| const gui = await pool.require('gui', true); | |
| gui.show(); | |
| }, | |
| }], | |
| label: Settings.Title, | |
| }); | |
| const pool_funcs = { | |
| core: { | |
| desc: '实现推书列表的增删改查', | |
| // 这里不用让FunctionLoader包装子存储,直接将list存储在autovote的全局作用域中即可 | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.core.func>>} core */ | |
| func() { | |
| // 内容更改监听器 | |
| /** @type {((val: Book[]) => any)[]} */ | |
| const listeners = []; | |
| GM_addValueChangeListener('accounts', (key, old_val, new_val, remote) => { | |
| // 防抖,比对确认确实存在数据差异再回调 | |
| // 仅当前网页已登录帐号下的自动推书配置改变时,才调用回调 | |
| const str_uid = utils.getUserID().toString(); | |
| const [old_account, new_account] = [old_val, new_val].map(val => val[str_uid]); | |
| if (utils.deepEqual(old_account, new_account)) return; | |
| listeners.forEach(l => debugging.callWithErrorHandling(l, null, [new_account.list])); | |
| }); | |
| /** | |
| * 添加一本书到自动推书 | |
| * @param {Book} book | |
| * @returns {boolean} 成功添加 / 已经在推书列表中 | |
| */ | |
| function add(book) { | |
| if (has(book.aid)) { return false; } | |
| /** @type {Record<string, AccountConfig>} */ | |
| const accounts = GM_getValue('accounts'); | |
| const account = getAccount(accounts, true); | |
| account.list.push(book); | |
| GM_setValue('accounts', accounts); | |
| return true; | |
| } | |
| /** | |
| * 直接设置整个books数组 | |
| * @overload | |
| * @param {Book[]} books | |
| * @returns {void} | |
| */ | |
| /** | |
| * 设置某一已在推书列表中的书籍的推书票数 | |
| * @overload | |
| * @param {number} aid | |
| * @param {number} votes | |
| * @returns {boolean} | |
| */ | |
| function set(...args) { | |
| // 直接设置整个books数组 | |
| if (args.length === 1) { | |
| const books = args[0]; | |
| /** @type {Record<string, AccountConfig>} */ | |
| const accounts = GM_getValue('accounts'); | |
| const account = getAccount(accounts, true); | |
| account.list = books; | |
| GM_setValue('accounts', accounts); | |
| return true; | |
| } | |
| // 设置某一已在推书列表中的书籍的推书票数 | |
| if (args.length === 2) { | |
| const [aid, votes] = args; | |
| if (!has(aid)) { return false; } | |
| /** @type {Record<string, AccountConfig>} */ | |
| const accounts = GM_getValue('accounts'); | |
| const account = getAccount(accounts, true); | |
| const books = account.list; | |
| books.find(b => b.aid === aid).votes = votes; | |
| GM_setValue('accounts', accounts); | |
| return true; | |
| } | |
| throw new TypeError('autovote.core.set: arguments\' length invalid'); | |
| } | |
| /** | |
| * 检查某一本书是否在推书列表中 | |
| * @param {number} aid | |
| * @returns {boolean} | |
| */ | |
| function has(aid) { | |
| const books = list(); | |
| return books.some(book => book.aid === aid); | |
| } | |
| /** | |
| * 获取全部 | |
| * @returns {Book[]} | |
| */ | |
| function list() { | |
| return getAccount().list; | |
| } | |
| /** | |
| * 获取推书记录 | |
| * @returns {VoteRecord} | |
| */ | |
| function getRecord() { | |
| const account = getAccount(); | |
| return account.record; | |
| } | |
| /** | |
| * 设置推书记录 | |
| * @param {VoteRecord} record | |
| */ | |
| function setRecord(record) { | |
| const accounts = GM_getValue('accounts'); | |
| const account = getAccount(accounts, true); | |
| account.record = record; | |
| GM_setValue('accounts', accounts); | |
| } | |
| /** | |
| * 检查当前网页已登录帐号是否启用了自动推书,综合考虑总体模块开关和账户内部开关 | |
| * @returns {boolean} | |
| */ | |
| function isEnabled() { | |
| const account = getAccount(); | |
| return GM_getValue('enabled') && account.enabled; | |
| } | |
| /** | |
| * 获取当前网页已登录帐号下的自动推书配置 | |
| * @param {Record<string, AccountConfig>} [accounts] - 如果提供,就从中获取当前帐号配置,否则自动读取存储中当前帐号的配置 | |
| * @param {boolean} [forWrite=false] - 是否后续准备写入,如果为true,则当该帐号配置不存在时,将默认配置赋值到给出的accounts[uid]中;默认为false | |
| * @returns {AccountConfig} | |
| */ | |
| function getAccount(accounts, forWrite = false) { | |
| const uid = utils.getUserID(); | |
| const str_uid = uid.toString(); | |
| const account = (accounts ?? GM_getValue('accounts'))[str_uid] ?? getDefaultAccount(); | |
| forWrite && (accounts[str_uid] = account); | |
| return account; | |
| } | |
| /** | |
| * 返回一个帐号下的推书配置默认值 | |
| * @returns {AccountConfig} | |
| */ | |
| function getDefaultAccount() { | |
| return { | |
| list: [], | |
| record: { | |
| last_voted: 0, | |
| vote_status: {}, | |
| }, | |
| enabled: true, | |
| }; | |
| } | |
| /** | |
| * 添加稍后列表值改变监听器 | |
| * @param {(val: Book[]) => any} listener | |
| */ | |
| function onChange(listener) { | |
| listeners.push(listener); | |
| } | |
| return { add, set, has, list, getRecord, setRecord, isEnabled, onChange }; | |
| } | |
| }, | |
| bookpage: { | |
| desc: '在书籍信息页侧边栏添加自动推书按钮', | |
| checkers: [{ | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/articleinfo.php' | |
| }], | |
| detectDom: '.main.m_foot', | |
| dependencies: ['core'], | |
| async func() { | |
| /** @type {core} */ | |
| const core = pool.require('core'); | |
| /** @type {sidepanel} */ | |
| const sidepanel = await require('sidepanel', true); | |
| const aid = parseInt(new URLSearchParams(location.search).get('id') | |
| ?? location.pathname.match(/\/book\/(\d+)\.htm/)[1], 10); | |
| const name = $('#content > div:first-child > table:first-child > tbody > tr:first-child > td > table span > b').innerText.trim(); | |
| const cover = $('#content > div:first-child > table:nth-of-type(2) img').src; | |
| core.isEnabled() && sidepanel.registerButton({ | |
| id: 'autovote.add', | |
| label: CONST.Text.Autovote.Add, | |
| icon: 'playlist_add', | |
| index: 5, | |
| callback() { | |
| const Autovote = CONST.Text.Autovote; | |
| const time_added = Date.now(); | |
| const success = core.add({ aid, name, cover, votes: 1, time_added, voted: 0 }); | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: Autovote.Added, | |
| caption: replaceText( | |
| success ? Autovote.AddSuccess : Autovote.AddDuplicate, | |
| { '{Name}': name } | |
| ), | |
| icon: success ? 'done' : 'lightbulb', | |
| group: 'autovote.added', | |
| }); | |
| } | |
| }); | |
| } | |
| }, | |
| gui: { | |
| desc: '在书架、书籍信息页和设置界面中展示的自动推书配置界面', | |
| dependencies: ['core'], | |
| detectDom: 'body', | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.gui.func>>} gui */ | |
| async func() { | |
| /** @type {core} */ | |
| const core = pool.require('core'); | |
| const container = $CrE('div'); | |
| const UI = CONST.Text.Autovote.UI; | |
| container.innerHTML = ` | |
| <q-dialog v-model="visible" full-width full-height class="plus-autovote"> | |
| <q-layout container view="hHh lpR fFf"> | |
| <q-header bordered class="bg-primary text-white"> | |
| <q-toolbar> | |
| <q-toolbar-title> | |
| <q-avatar icon="collections_bookmark"></q-avatar> | |
| ${ UI.Title } | |
| </q-toolbar-title> | |
| <q-btn icon="close" flat v-close-popup></q-btn> | |
| </q-toolbar> | |
| </q-header> | |
| <q-page-container> | |
| <q-page class="bg-lightdark q-pa-md"> | |
| <!-- 已登录时,展示主要内容 --> | |
| <div v-if="books" class="row justify-start"> | |
| <!-- 每一本书一个QCard --> | |
| <!-- 利用Quasar flex的内置换行机制,实现不同大小屏幕不同列数 --> | |
| <div v-for="(book, i) of books" class="col-xl-3 col-lg-4 col-md-6 col-sm-12 col-xs-12" style="padding: 1em"> | |
| <q-card> | |
| <q-card-section horizontal> | |
| <!-- 封面 --> | |
| <q-card-section class="col-2"> | |
| <a :href="book_urls[book.aid]" style="width: 100%;" target="_blank"> | |
| <q-img :src="book.cover"></q-img> | |
| </a> | |
| </q-card-section> | |
| <!-- 文字 --> | |
| <q-card-section class="col-5 text-body2 column justify-evenly"> | |
| <!-- 带超链接的标题 --> | |
| <div class="text-h6 col-5"> | |
| <a :href="book_urls[book.aid]" target="_blank">{{ book.name }}</a> | |
| </div> | |
| <!-- 添加时间 --> | |
| <div class="col-3"> | |
| ${ UI.TimeAdded }{{ new Date(book.time_added).toLocaleDateString() }} | |
| </div> | |
| <!-- 总推书次数 --> | |
| <div class="col-3"> | |
| ${ UI.VotedCount }{{ book.voted }} | |
| </div> | |
| </q-card-section> | |
| <!-- 右侧操作区域 --> | |
| <q-card-actions class="col-5 row"> | |
| <!-- 设置推书次数 --> | |
| <div class="col-8"> | |
| <q-input v-model.number="book.votes" type="number" label="${ UI.Votes }"></q-input> | |
| </div> | |
| <!-- 移除推书项按钮 --> | |
| <div class="col-4 row items-center justify-center"> | |
| <q-btn icon="delete_outline" @click="userRemove(book.aid)" flat></q-btn> | |
| </div> | |
| </q-card-actions> | |
| </q-card-section> | |
| </q-card> | |
| </div> | |
| </div> | |
| <!-- 未登录提示 --> | |
| <div v-else class="absolute-center text-h6"> | |
| ${ UI.NotLoggedIn } | |
| </div> | |
| <!-- 添加新推书项悬浮按钮 --> | |
| <q-page-sticky position="bottom-right" :offset="[18, 18]"> | |
| <q-btn fab icon="add" color="accent" @click="userAdd"></q-btn> | |
| </q-page-sticky> | |
| </q-page> | |
| </q-page-container> | |
| <q-footer bordered class="bg-lightdark text-lightdark"> | |
| <q-toolbar> | |
| <q-toolbar-title class="text-body1 q-gutter-md row"> | |
| <span v-if="books" class="col q-gutter-md"> | |
| <span>${ UI.TotalVotes }{{ total_votes }}</span> | |
| <span>${ UI.TotalBooks }{{ total_books }}</span> | |
| </span> | |
| </q-toolbar-title> | |
| </q-toolbar> | |
| </q-footer> | |
| </q-layout> | |
| </q-dialog> | |
| <q-dialog v-model="addbook_visible"> | |
| <q-card style="width: 60vw; min-width: 60em; max-width: 90vw;"> | |
| <!-- 标题栏 --> | |
| <q-card-section> | |
| <q-toolbar> | |
| <q-avatar icon="book"></q-avatar> | |
| <q-toolbar-title>${ UI.AddbookTitle }</q-toolbar-title> | |
| <q-btn icon="close" flat v-close-popup></q-btn> | |
| </q-toolbar> | |
| </q-card-section> | |
| <!-- 内容 --> | |
| <q-card-section> | |
| <q-list> | |
| <!-- 书籍选择 --> | |
| <p-book-search | |
| label="${ UI.AddbookBook }" | |
| v-model="addbook_aid" | |
| ></p-book-search> | |
| <!-- 推书次数 --> | |
| <q-item> | |
| <!-- 提示文本 --> | |
| <q-item-section> | |
| <q-item-label> | |
| ${ UI.AddbookVotes } | |
| </q-item-label> | |
| </q-item-section> | |
| <!-- 数字输入框 --> | |
| <q-item-section> | |
| <q-input type="number" v-model.number="addbook_votes"></q-input> | |
| </q-item-section> | |
| </q-item> | |
| </q-list> | |
| </q-card-section> | |
| <!-- 操作按钮 --> | |
| <q-card-actions align="right"> | |
| <q-btn label="${ UI.AddbookOk }" @click="addBook" flat> | |
| </q-card-actions> | |
| </q-card> | |
| </q-dialog> | |
| `; | |
| document.body.append(container); | |
| let instance; | |
| const app = Vue.createApp({ | |
| data() { | |
| return { | |
| // 主窗口 | |
| /** | |
| * 是否可见 | |
| * @type {boolean} | |
| */ | |
| visible: false, | |
| /** | |
| * 全部推书项 | |
| * @type {Book[] | null} | |
| */ | |
| books: utils.isLoggedIn() ? core.list() : null, | |
| // 添加推书项窗口 | |
| /** | |
| * 是否可见 | |
| * @type {boolean} | |
| */ | |
| addbook_visible: false, | |
| /** | |
| * 用户选择的书籍id | |
| * 书籍选择器的v-model绑定 | |
| * @type {number | null} | |
| */ | |
| addbook_aid: null, | |
| /** | |
| * 推书次数 | |
| * 数字输入框的v-model绑定 | |
| */ | |
| addbook_votes: 0, | |
| }; | |
| }, | |
| computed: { | |
| /** | |
| * 根据书籍aid自动合成的书籍信息页链接 | |
| * @type {Record<number | string, string>} | |
| */ | |
| book_urls() { | |
| return this.books.reduce((urls, book) => | |
| Object.assign(urls, { [book.aid]: `/book/${ book.aid }.htm`}), {}); | |
| }, | |
| /** | |
| * 已分配的总票数 | |
| * @type {number} | |
| */ | |
| total_votes() { | |
| /** @type {Book[]} */ | |
| const books = this.books; | |
| return books.reduce((num, book) => num + (typeof book.votes === 'number' ? book.votes : 0), 0); | |
| }, | |
| /** | |
| * 所有参与推荐的小说数 | |
| * @type {number} | |
| */ | |
| total_books() { | |
| return this.books.length; | |
| }, | |
| }, | |
| methods: { | |
| /** | |
| * 用户请求删除一个自动推书项(即一本书) | |
| * @param {number} aid | |
| */ | |
| userRemove(aid) { | |
| const book = this.books.find(b => b.aid === aid); | |
| Quasar.Dialog.create({ | |
| title: UI.ConfirmRemove.Title, | |
| message: replaceText( | |
| UI.ConfirmRemove.Message, | |
| { '{Name}': book.name } | |
| ), | |
| ok: { | |
| label: UI.ConfirmRemove.Ok, | |
| color: 'primary', | |
| }, | |
| cancel: { | |
| label: UI.ConfirmRemove.Cancel, | |
| color: 'secondary', | |
| }, | |
| }).onOk(() => this.books.splice(this.books.findIndex(book => book.aid === aid), 1)) | |
| }, | |
| /** | |
| * 用户请求添加一个自动推书项 | |
| */ | |
| async userAdd() { | |
| this.addbook_visible = true; | |
| }, | |
| /** | |
| * 用户提交添加推书项 | |
| */ | |
| async addBook() { | |
| // 获取书籍信息 | |
| /** @type {number} */ | |
| const aid = this.addbook_aid; | |
| const xml = await api.getNovelInfo({ aid, lang: utils.getLanguage() }); | |
| /** @type {string} */ | |
| const name = $(xml, 'data[name="Title"]').firstChild.nodeValue; | |
| if (core.has(aid)) { | |
| // 防止重复添加 | |
| Quasar.Notify.create({ | |
| type: 'error', | |
| message: replaceText( | |
| UI.AddDuplicate, | |
| { '{Name}': name } | |
| ), | |
| group: 'autovote.gui.addbook.add_duplicate', | |
| }); | |
| } else { | |
| // 添加新推书项 | |
| core.add({ | |
| aid, name, | |
| votes: this.addbook_votes, | |
| cover: `http://img.wenku8.com/image/${ Math.floor(aid / 1000) }/${ aid }/${ aid }s.jpg`, | |
| time_added: Date.now(), | |
| voted: 0, | |
| }); | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: UI.Added, | |
| caption: UI.AddSuccess, | |
| group: 'autovote.gui.addbook.add_duplicate', | |
| }); | |
| this.addbook_visible = false; | |
| } | |
| } | |
| }, | |
| watch: { | |
| // 自动保存配置更改到存储空间 | |
| books: { | |
| handler(new_val, old_val) { | |
| core.set(new_val); | |
| }, | |
| deep: true | |
| }, | |
| }, | |
| mounted() { | |
| instance = this; | |
| // 自动根据存储的推书配置更新UI | |
| core.onChange(books => this.books = books); | |
| } | |
| }); | |
| app.use(Quasar); | |
| component.register(app, 'p-book-search'); | |
| app.mount(container); | |
| function show() { | |
| instance.visible = true; | |
| } | |
| function hide() { | |
| instance.visible = false; | |
| } | |
| if (FunctionLoader.testCheckers([{ | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/articleinfo.php' | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/bookcase.php' | |
| }]) && GM_getValue('enabled')) { | |
| require('sidepanel', true).then( | |
| /** @param {sidepanel} sidepanel */ | |
| sidepanel => { | |
| sidepanel.registerButton({ | |
| id: 'autovote.show', | |
| icon: 'edit_note', | |
| label: CONST.Text.Autovote.Configure, | |
| index: 5, | |
| callback: show, | |
| }); | |
| } | |
| ); | |
| } | |
| return { show, hide, }; | |
| }, | |
| }, | |
| vote: { | |
| desc: '每天执行一次推书任务', | |
| dependencies: ['core'], | |
| // 这里不用让FunctionLoader包装子存储,直接将推书记录存储在autovote的全局作用域中即可 | |
| async func() { | |
| /** @type {core} */ | |
| const core = pool.require('core'); | |
| // 当未登录时不自动推书 | |
| if (!utils.isLoggedIn()) return; | |
| const record = core.getRecord(); | |
| const books = core.list(); | |
| // 如果没有开启自动推书,停止运行 | |
| if (!core.isEnabled()) { | |
| logger.log('Info', 'Autovote: autovote not enabled'); | |
| return; | |
| } | |
| // 如果今日已经完成了自动推书,停止运行 | |
| const today_voted = new Date(record.last_voted).toDateString() === new Date().toDateString(); | |
| const vote_completed = books.every(book => record.vote_status[book.aid] >= book.votes); | |
| if (today_voted && vote_completed) { | |
| logger.log('Info', 'Autovote: today voted'); | |
| return; | |
| } | |
| // 如果有其他页面内的脚本实例正在执行推书任务,当前实例就不重复执行 | |
| const autovote_active = Date.now() - record.last_voted <= CONST.Internal.AutovoteActiveTimeout; | |
| if (autovote_active) { | |
| logger.log('Info', 'Autovote: voting active in another page'); | |
| return; | |
| } | |
| const voteBook = utils.toQueued(_voteBook, { | |
| max: 5, | |
| sleep: 0, | |
| queue_id: 'votebook' | |
| }); | |
| // 执行自动推书 | |
| logger.log('Info', 'Autovote: start voting'); | |
| Quasar.Notify.create({ | |
| type: 'info', | |
| message: CONST.Text.Autovote.VoteStart, | |
| group: 'autovote.vote', | |
| }); | |
| const divs = await doAutovote(); | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: CONST.Text.Autovote.VoteEnd, | |
| /*actions: [{ | |
| label: CONST.Text.Autovote.VoteDetail, | |
| handler() { | |
| Quasar.Dialog.create({ | |
| // | |
| }); | |
| } | |
| }],*/ | |
| group: 'autovote.vote', | |
| }); | |
| /** | |
| * 根据今日推书状态,为未推完部分执行自动推书 | |
| * @returns {Promise<Record<string, HTMLDivElement[]>>} { [书籍字符串aid]: (推书结果文档中的block)[] } | |
| */ | |
| async function doAutovote() { | |
| const record = core.getRecord(); | |
| const books = core.list(); | |
| // 筛选出今日未推完的书,并计算剩余推书票数 | |
| /** @type {Record<string, number>} 未推完的书及其剩余推书票数 */ | |
| const task = books.reduce((task, book) => { | |
| const str_aid = book.aid.toString(); | |
| // 上次自动推书是不是今天 | |
| const today_voted = new Date(record.last_voted).toDateString() === new Date().toDateString(); | |
| // 这本书每天应该推的总票数 | |
| const total = books.find(b => b.aid === book.aid).votes; | |
| // 这本书今日还应推的票数 | |
| const rest = today_voted ? Math.max(0, total - (record.vote_status[str_aid] ?? 0)) : total; | |
| rest > 0 && (task[str_aid] = rest); | |
| return task; | |
| }, {}); | |
| // 推书 | |
| const result = {}; | |
| await Promise.all(Object.entries(task).map(async ([str_aid, votes]) => { | |
| const aid = parseInt(str_aid, 10); | |
| const divs = await Promise.all(Array.from('a'.repeat(votes)).map((_, i) => voteBook(aid))); | |
| result[str_aid] = divs; | |
| }, {})); | |
| // 更新最后推书完成时间,确保哪怕没有任何书要推也每天仅执行一次 | |
| const new_record = core.getRecord(); | |
| new_record.last_voted = Date.now(); | |
| core.setRecord(new_record); | |
| return result; | |
| } | |
| /** | |
| * 执行推书一次(投一票),并记到推书记录中 | |
| * @param {number} aid | |
| * @returns {Promise<HTMLDivElement>} 返回的页面中的.block元素 | |
| */ | |
| async function _voteBook(aid) { | |
| // 推书 | |
| const str_aid = aid.toString(); | |
| const doc = await utils.requestDocument({ | |
| method: 'GET', | |
| url: `/modules/article/uservote.php?id=${str_aid}`, | |
| }); | |
| const block = $(doc, '.block'); | |
| block || logger.log('Warn', 'Autovote: .block not found in vote page', doc); | |
| // 记录 | |
| const record = core.getRecord(); | |
| const books = core.list(); | |
| // 如果上次自动推书不是今天,就先清除推书记录 | |
| const today_voted = new Date(record.last_voted).toDateString() === new Date().toDateString(); | |
| today_voted || (record.vote_status = {}); | |
| // 推书记录中为当前书籍已推书计数加一 | |
| record.vote_status[str_aid] = (record.vote_status[str_aid] ?? 0) + 1; | |
| // 自动推书配置中累计推书次数加一 | |
| books.find(b => b.aid === aid).voted++; | |
| // 更新推书记录中的时间 | |
| record.last_voted = Date.now(); | |
| // 保存 | |
| core.setRecord(record); | |
| core.set(books); | |
| return block; | |
| } | |
| } | |
| }, | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { | |
| GM_setValue, GM_getValue, GM_addValueChangeListener | |
| }); | |
| await promise; | |
| }, | |
| }, | |
| reviewcollection: { | |
| desc: '书评收藏', | |
| dependencies: ['dependencies', 'utils', 'configs', 'storageupdater', 'mousetip'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_listValues', 'GM_deleteValue', 'GM_addValueChangeListener'], | |
| async func(GM_setValue, GM_getValue, GM_listValues, GM_deleteValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {configs} */ | |
| const configs = require('configs'); | |
| /** @type {storageupdater} */ | |
| const storageupdater = require('storageupdater'); | |
| /** @type {mousetip} */ | |
| const mousetip = require('mousetip'); | |
| /** | |
| * 记录书评的楼层高度信息,用于判断是否有新楼层 | |
| * @typedef {Object} ReviewRecord | |
| * @property {number} top - 目前已记录的最高楼层号,用于判断是否有新楼层 | |
| * @property {number} last_check - 上次检查楼层更新的时间 | |
| * @property {boolean} has_new - 是否已检查发现有新楼层 | |
| */ | |
| /** | |
| * @typedef {Object} Review | |
| * @property {number} rid | |
| * @property {string} name | |
| * @property {ReviewRecord} record - 最高楼层信息,用于判断是否有新楼层 | |
| * @property {number} last_active - 上次查看此书评时间,用于超时自动移除收藏 | |
| */ | |
| GM_getValue = utils.defaultedGet({ | |
| /** @type {Review[]} */ | |
| reviews: CONST.Internal.BuiltinReviewCollection, | |
| /** @type {boolean} */ | |
| enabled: true, | |
| /** @type {'left' | 'right'} */ | |
| list_position: 'left', | |
| /** @type {boolean} */ | |
| open_lastpage: false, | |
| /** @type {number} */ | |
| check_interval: 12, | |
| /** @type {boolean} */ | |
| add_on_reply: false, | |
| /** @type {number} */ | |
| auto_remove_timeout: -1, | |
| 'config_version': 2, | |
| }, GM_getValue); | |
| // 存储数据更新 | |
| storageupdater.update([ | |
| function v0_v1(config) { | |
| /** @type {Review[]} */ | |
| const reviews = config.reviews; | |
| reviews.forEach(review => review.record = { | |
| has_new: true, | |
| last_check: 0, | |
| top: 0, | |
| }); | |
| return config; | |
| }, | |
| function v1_v2(config) { | |
| /** @type {Review[]} */ | |
| const reviews = config.reviews; | |
| const now = Date.now(); | |
| reviews && reviews.forEach(review => review.last_active = now); | |
| return config; | |
| } | |
| ], { GM_getValue, GM_setValue, GM_listValues, GM_deleteValue }); | |
| const Settings = CONST.Text.ReviewCollection.Settings; | |
| configs.registerConfig('reviewcollection', { | |
| GM_addValueChangeListener, | |
| items: [{ | |
| type: 'boolean', | |
| label: Settings.Enabled, | |
| caption: Settings.EnabledCaption, | |
| key: 'enabled', | |
| get() { return GM_getValue('enabled'); }, | |
| set(val) { GM_setValue('enabled', val); }, | |
| }, { | |
| type: 'select', | |
| options: [{ | |
| label: Settings.ListPositionLeft, | |
| value: 'left', | |
| }, { | |
| label: Settings.ListPositionRight, | |
| value: 'right', | |
| }], | |
| label: Settings.ListPosition, | |
| caption: Settings.ListPositionCaption, | |
| key: 'list_position', | |
| get() { return GM_getValue('list_position'); }, | |
| set(val) { GM_setValue('list_position', val); }, | |
| }, { | |
| type: 'boolean', | |
| label: Settings.OpenLastPage, | |
| caption: Settings.OpenLastPageCaption, | |
| key: 'open_lastpage', | |
| get() { return GM_getValue('open_lastpage'); }, | |
| set(val) { GM_setValue('open_lastpage', val); }, | |
| }, { | |
| type: 'number', | |
| label: Settings.NewFloorCheckInterval, | |
| caption: Settings.NewFloorCheckIntervalCaption, | |
| reload: true, | |
| key: 'check_interval', | |
| get() { return GM_getValue('check_interval'); }, | |
| set(val) { GM_setValue('check_interval', val); }, | |
| }, { | |
| type: 'boolean', | |
| label: Settings.AddOnReply, | |
| caption: Settings.AddOnReplyCaption, | |
| key: 'add_on_reply', | |
| get() { return GM_getValue('add_on_reply'); }, | |
| set(val) { GM_setValue('add_on_reply', val); }, | |
| }, { | |
| type: 'number', | |
| label: Settings.AutoRemoveTimeout, | |
| caption: Settings.AutoRemoveTimeoutCaption, | |
| key: 'auto_remove_timeout', | |
| get() { return GM_getValue('auto_remove_timeout'); }, | |
| set(val) { GM_setValue('auto_remove_timeout', val); }, | |
| }], | |
| label: Settings.Title, | |
| }); | |
| const pool_funcs = { | |
| /* | |
| gui: { | |
| desc: '收藏书评管理界面', | |
| async func() { | |
| const container = $CrE('div'); | |
| container.innerHTML = ` | |
| <q-dialog v-model="visible"> | |
| </q-dialog> | |
| `; | |
| }, | |
| }, | |
| */ | |
| indexlist: { | |
| desc: '在首页展示收藏的书评列表', | |
| checkers: [{ | |
| type: 'path', | |
| value: '/' | |
| }, { | |
| type: 'path', | |
| value: '/index.php' | |
| }], | |
| async func() { | |
| // 页面内列表 | |
| makeList(); | |
| configs.registerUpdateCallback('reviewcollection', (key, old_val, new_val, remote) => { | |
| switch (key) { | |
| case 'enabled': | |
| new_val ? makeList() : $('#plus-review-collection')?.remove(); | |
| break; | |
| case 'list_position': | |
| case 'open_lastpage': | |
| makeList(); | |
| break; | |
| } | |
| }); | |
| GM_addValueChangeListener('reviews', () => makeList()); | |
| addStyle(` | |
| .ultop { | |
| overflow-x: hidden; | |
| } | |
| .plus-badge { | |
| position: relative; | |
| } | |
| .plus-badge::before { | |
| content: ""; | |
| position: absolute; | |
| top: -5px; | |
| left: -10px; | |
| width: 10px; | |
| height: 10px; | |
| background: var(--plus-text-poptext); | |
| border-radius: 50%; | |
| } | |
| .plus-darkmode .plus-badge::before{ | |
| background: #f36d55; | |
| } | |
| `); | |
| /** | |
| * 创建书评列表展示框并添加到DOM,如DOM已有展示框就替换掉旧的 | |
| */ | |
| function makeList() { | |
| // 如果没有启用就不创建 | |
| if (!GM_getValue('enabled')) { return; } | |
| /** @type {Review[]} */ | |
| const reviews = GM_getValue('reviews'); | |
| // 制作列表 | |
| const block = $$CrE({ | |
| tagName: 'div', | |
| classes: 'block', | |
| props: { | |
| innerHTML: ` | |
| <div class="blocktitle"> | |
| <span class="txt">${ CONST.Text.ReviewCollection.CollectionTitle }</span> | |
| <span class="txtr"></span> | |
| </div> | |
| <div class="blockcontent"> | |
| <ul class="ultop"></ul> | |
| </div> | |
| `, | |
| }, | |
| attrs: { | |
| id: 'plus-review-collection', | |
| }, | |
| }); | |
| const ul = $(block, '.ultop'); | |
| reviews.forEach(review => { | |
| const url = `https://${ location.host }/modules/article/reviewshow.php?rid=${ review.rid }&page=${ GM_getValue('open_lastpage') ? 'last' : '1' }`; | |
| const li = $CrE('li'); | |
| const a = $$CrE({ | |
| tagName: 'a', | |
| attrs: { | |
| href: url, | |
| target: '_blank', | |
| }, | |
| props: { | |
| innerText: review.name, | |
| }, | |
| classes: review.record.has_new ? ['plus-badge'] : [], | |
| }); | |
| const tip = (review.record.has_new ? CONST.Text.ReviewCollection.HasNewFloors : '') + review.name; | |
| mousetip.set(a, tip); | |
| li.append(a); | |
| ul.append(li); | |
| }); | |
| // 添加到页面 | |
| $('#plus-review-collection')?.remove(); | |
| const parent = $(({ | |
| left: '#left', | |
| right: '#right', | |
| }) [GM_getValue('list_position')]); | |
| parent.append(block); | |
| } | |
| }, | |
| }, | |
| reviewbutton: { | |
| desc: '在书评页面添加收藏按钮', | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/reviewshow.php', | |
| }, | |
| dependencies: ['checker'], | |
| async func() { | |
| /** @type {checker} */ | |
| const checker = pool.require('checker'); | |
| /** @type {sidepanel} */ | |
| const sidepanel = await require('sidepanel', true); | |
| const rid = parseInt(new URLSearchParams(location.search).get('rid'), 10); | |
| toggleSideButton(); | |
| ['enabled', 'reviews'].forEach(key => GM_addValueChangeListener(key, (key, old_val, new_val, remote) => toggleSideButton())); | |
| /** | |
| * 根据enabled,注册或移除侧边栏收藏按钮 | |
| * @param {boolean} [enabled] | |
| */ | |
| function toggleSideButton(enabled=null) { | |
| enabled === null && (enabled = GM_getValue('enabled')); | |
| const ButtonID = 'reviewcollection.toggle'; | |
| const ReviewCollection = CONST.Text.ReviewCollection; | |
| let in_collection = GM_getValue('reviews').some(r => r.rid === rid); | |
| if (enabled) { | |
| sidepanel.hasButton(ButtonID) ? | |
| sidepanel.updateButton(ButtonID, { | |
| label: in_collection ? ReviewCollection.Remove : ReviewCollection.Add, | |
| icon: in_collection ? 'bookmark' : 'bookmark_border', | |
| }) : | |
| sidepanel.registerButton({ | |
| id: ButtonID, | |
| label: in_collection ? ReviewCollection.Remove : ReviewCollection.Add, | |
| icon: in_collection ? 'bookmark' : 'bookmark_border', | |
| index: 2, | |
| async callback() { | |
| // 添加收藏需要时间(以fetch最后一页获取最高楼层号),按钮置为工作中状态 | |
| sidepanel.updateButton(ButtonID, { | |
| loading: true, | |
| }); | |
| // 修改书评收藏 | |
| const in_collection = await toggleCurrentReview(); | |
| // 提示 | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: in_collection ? ReviewCollection.Added : ReviewCollection.Removed, | |
| group: 'reviewcollection.toggle' | |
| }); | |
| // 更新按钮 | |
| sidepanel.updateButton(ButtonID, { | |
| loading: false, | |
| label: in_collection ? ReviewCollection.Remove : ReviewCollection.Add, | |
| icon: in_collection ? 'bookmark' : 'bookmark_border', | |
| }); | |
| } | |
| }); | |
| } else { | |
| sidepanel.hasButton(ButtonID) && sidepanel.removeButton(ButtonID); | |
| } | |
| } | |
| } | |
| }, | |
| addonreply: { | |
| desc: '回复时自动加入收藏', | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/reviewshow.php', | |
| }, | |
| detectDom: 'form[name="frmreview"]', | |
| func() { | |
| const form = $('form[name="frmreview"]'); | |
| $AEL(form, 'submit', e => { | |
| if (!GM_getValue('add_on_reply')) { return; } | |
| // 添加收藏 | |
| toggleCurrentReview(true); | |
| }); | |
| }, | |
| }, | |
| checker: { | |
| desc: '定期检查是否有新楼层、清理未访问书评', | |
| // 检查发现有新楼层时,记录下来,根据新楼层记录在界面上提示用户;当用户打开对应帖子页面时,清除新楼层记录,刷新最高楼层记录 | |
| /** @typedef {Awaited<ReturnType<typeof pool_funcs.checker.func>>} checker */ | |
| async func() { | |
| const pool_funcs = { | |
| newfloor: { | |
| desc: '检查新楼层', | |
| async func() { | |
| /** @type {number} */ | |
| const check_interval = GM_getValue('check_interval'); | |
| const check_interval_ms = check_interval * 60 * 60 * 1000; | |
| const check_interval_inpage = Math.max(CONST.Internal.ReviewUpdateMinCheckInterval, check_interval_ms); | |
| if (check_interval < 0) { return; } | |
| // 打开页面时,自动检查一次 | |
| doCheck(); | |
| // 在页面内,每过一段时间自动检查一次 | |
| // 即使设置了极短的检查间隔,这段时间间隔不能短于一定最短长度,防止快速产生大量请求 | |
| // 如需快速即时检查是否有更新,可以打开书评最后一页,利用页面自动更新检查;或手动刷新页面 | |
| setInterval(doCheck, check_interval_inpage); | |
| async function doCheck() { | |
| /** @type {Review[]} */ | |
| const reviews = GM_getValue('reviews'); | |
| const now = Date.now(); | |
| let modified = false; | |
| for (const review of reviews) { | |
| if (now - review.record.last_check < check_interval_ms) { | |
| // 未到检查最短时间间隔 | |
| continue; | |
| } | |
| // 获取当前最高楼层号 | |
| const top = await getLastFloorNumber(review.rid); | |
| review.record.last_check = now; | |
| modified = true; | |
| // 和存储的最高楼层号比对,检查是否有新楼层 | |
| if (top > review.record.top) { | |
| // 记录:此帖有新楼层 | |
| review.record.has_new = true; | |
| // 记录:新的最高楼层号 | |
| review.record.top = top; | |
| } | |
| } | |
| modified && GM_setValue('reviews', reviews); | |
| } | |
| } | |
| }, | |
| record: { | |
| desc: '书评页清除新楼层记录', | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/reviewshow.php', | |
| }, | |
| async func() { | |
| /** @type {Review[]} */ | |
| const reviews = GM_getValue('reviews'); | |
| const rid = parseInt(new URLSearchParams(location.search).get('rid'), 10); | |
| const review = reviews.find(review => review.rid === rid); | |
| if (review) { | |
| await doRecord(); | |
| require('review', true).then( | |
| /** @param {review} review */ | |
| review => { | |
| $AEL(review.messager, 'update', e => doRecord()); | |
| } | |
| ); | |
| } | |
| async function doRecord() { | |
| // 若当前页面最大楼层号大于等于本书评记录的最高楼层号,则可清除新楼层记录 | |
| /** @type {Review[]} */ | |
| const reviews = GM_getValue('reviews'); | |
| const review = reviews.find(review => review.rid === rid); | |
| if (!review) { return; } | |
| const page_top = await getLastFloorNumber(review.rid, document); | |
| if (page_top >= review.record.top) { | |
| if (!document.hidden) { | |
| // 标签页可见时,清除新楼层记录 | |
| review.record.has_new = false; | |
| } else if (page_top > review.record.top) { | |
| // 标签页不可见,且楼层有更新时,记下新楼层记录 | |
| review.record.has_new = true; | |
| } else { | |
| // 其余情况:标签页不可见且无新楼层,无数据更新,仅更新last_check即可 | |
| // 这个else分支什么都不用做 | |
| } | |
| // 刷新最高楼层记录 | |
| review.record.top = page_top; | |
| review.record.last_check = Date.now(); | |
| // 保存 | |
| GM_setValue('reviews', reviews); | |
| } | |
| } | |
| } | |
| }, | |
| removeinactive: { | |
| desc: '清除长时间未访问的书评收藏', | |
| func() { | |
| /** @type {Review[]} */ | |
| const reviews = GM_getValue('reviews'); | |
| const now = Date.now(); | |
| const timeout = GM_getValue('auto_remove_timeout'); | |
| if (timeout < 0) { return; } | |
| /** @type {Review[]} */ | |
| const inactive_reviews = []; | |
| reviews.forEach(review => { | |
| const inactive = now - review.last_active > timeout; | |
| inactive && inactive_reviews.push(review); | |
| }); | |
| const active_reviews = reviews.filter(r => inactive_reviews.every(rw => rw.rid !== r.rid)); | |
| GM_setValue('reviews', active_reviews); | |
| } | |
| }, | |
| activate: { | |
| desc: '书评页记录书评访问', | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/reviewshow.php', | |
| }, | |
| func() { | |
| /** @type {Review[]} */ | |
| const reviews = GM_getValue('reviews'); | |
| const rid = parseInt(new URLSearchParams(location.search).get('rid'), 10); | |
| const review = reviews.find(review => review.rid === rid); | |
| if (!review) { return; } | |
| review.last_active = Date.now(); | |
| GM_setValue('reviews', reviews); | |
| } | |
| } | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs); | |
| await promise; | |
| /** | |
| * 获取给定书评最高楼层号 | |
| * @param {number} rid | |
| * @param {Document} [doc] - 如果提供此参数,则直接从中获取最高楼层;否则发起网络请求该书评最后一页,再获取最高楼层 | |
| * @returns | |
| */ | |
| async function getLastFloorNumber(rid, doc=null) { | |
| doc = doc ?? await utils.requestDocument({ | |
| method: 'GET', | |
| url: `https://${location.host}/modules/article/reviewshow.php?rid=${rid}&page=last`, | |
| }); | |
| /** @type {HTMLAnchorElement[]} */ | |
| const links = $All(doc, '#content > table > tbody > tr > td:last-of-type > div:nth-of-type(2) > a[href^="#yid"]'); | |
| const last = links[links.length-1]; | |
| const number = parseInt(last.innerText.match(/\d+/)[0], 10); | |
| return number; | |
| } | |
| return { getLastFloorNumber, }; | |
| } | |
| }, | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { | |
| GM_setValue, GM_getValue, GM_addValueChangeListener | |
| }); | |
| await promise; | |
| /** | |
| * 在书评页面执行,为当前页面的书评切换收藏/未收藏状态 | |
| * @param {boolean} [target=null] - 是希望添加收藏(true)还是移除收藏(false),如果发现已在收藏列表/不在收藏列表就什么也不做;省略此参数时,自动切换收藏状态,即已在收藏列表时移除收藏、不在收藏列表时添加收藏 | |
| * @returns {Promise<boolean>} 切换后是否为已收藏状态 | |
| */ | |
| async function toggleCurrentReview(target = null) { | |
| /** @type {checker} */ | |
| const checker = pool.require('checker'); | |
| /** @type {Review[]} */ | |
| const reviews = GM_getValue('reviews'); | |
| const rid = parseInt(new URLSearchParams(location.search).get('rid'), 10); | |
| let name = $('#content > table.grid th > strong').innerText.trim(); | |
| name.includes(':') && (name = name.split(':')[1]); | |
| /** @type {ReviewRecord} */ | |
| const record = { | |
| top: await checker.getLastFloorNumber(rid), | |
| last_check: Date.now(), | |
| has_new: false, | |
| }; | |
| const in_collection = reviews.some(r => r.rid === rid); | |
| if (target !== false && !in_collection) { | |
| // 需要添加书评收藏 | |
| const last_active = Date.now(); | |
| reviews.push({ rid, name, record, last_active }); | |
| } else if (target !== true && in_collection) { | |
| // 需要移除书评收藏 | |
| const index = reviews.findIndex(r => r.rid === rid); | |
| reviews.splice(index, 1); | |
| } | |
| GM_setValue('reviews', reviews); | |
| return !in_collection; | |
| } | |
| }, | |
| }, | |
| background: { | |
| desc: '自定义页面背景', | |
| detectDom: 'body', | |
| dependencies: ['utils', 'configs', 'storageupdater'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_listValues', 'GM_deleteValue', 'GM_addValueChangeListener'], | |
| func(GM_setValue, GM_getValue, GM_listValues, GM_deleteValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {configs} */ | |
| const configs = require('configs'); | |
| /** @type {storageupdater} */ | |
| const storageupdater = require('storageupdater'); | |
| /** | |
| * @typedef {'local' | 'url' | 'color'} BGType | |
| */ | |
| GM_getValue = utils.defaultedGet({ | |
| /** @type {boolean} */ | |
| enabled: false, | |
| /** @type {BGType} */ | |
| type: 'color', | |
| /** @type {string} */ | |
| image_url: '', | |
| /** @type {'contain' | 'cover' | 'fill' | 'none' | 'scale-down'} */ | |
| image_fit: 'fill', | |
| /** @type {number} */ | |
| mask_opacity: 0.5, | |
| /** @type {boolean} */ | |
| mask_blur: false, | |
| /** @type {string} */ | |
| color: 'rgb(255, 255, 255)', | |
| 'config_version': 1, | |
| }, GM_getValue); | |
| // 存储数据更新 | |
| storageupdater.update([ | |
| function v0_v1(config) { | |
| // 去除透明度部分 | |
| const reg = /rgba\((\d+, *\d+, *\d+), *\d+(\.\d*)?\)/; | |
| const match = config.color.match(reg); | |
| if (match) { | |
| config.color = `rgb(${ match[1] })`; | |
| } | |
| return config; | |
| }, | |
| ], { GM_setValue, GM_getValue, GM_listValues, GM_deleteValue }); | |
| // 创建背景 | |
| /** | |
| * 背景管理器 | |
| * @typedef {{ install: function, update: function, uninstall: function }} BackgroundManager | |
| */ | |
| /** | |
| * 当前已经应用背景的管理器 | |
| * @type {BackgroundManager | null} | |
| */ | |
| let cur_bg = null; | |
| /** | |
| * 已实现的全部背景管理器 | |
| * @satisfies {Record<string, BackgroundManager>} | |
| */ | |
| const BG = { | |
| image: { | |
| /** | |
| * @param {string} url | |
| * @param {number} mask_opacity | |
| */ | |
| install(url, mask_opacity, image_fit, mask_blur) { | |
| // 背景图片 | |
| const img = $$CrE({ | |
| tagName: 'img', | |
| attrs: { | |
| src: url, | |
| id: 'plus-background-img', | |
| }, | |
| styles: { | |
| position: 'fixed', | |
| left: '0', | |
| top: '0', | |
| width: '100vw', | |
| height: '100vh', | |
| zIndex: '-2', | |
| display: url ? 'block' : 'none', | |
| objectFit: image_fit, | |
| }, | |
| }); | |
| // 创建一个position: fixed的div,防止内容撑高页面滚动高度 | |
| const fixed_div = $$CrE({ | |
| tagName: 'div', | |
| attrs:{ | |
| id: 'plus-background-mask-positioner', | |
| }, | |
| styles: { | |
| position: 'fixed', | |
| left: '0', | |
| top: '0', | |
| width: '100vw', | |
| height: '100vh', | |
| zIndex: '-1', | |
| overflow: 'auto', | |
| }, | |
| }); | |
| // fixed_div内部创建和网页标准文档流等高的矩形元素,使fixed_div内部滚动条和标准文档流一致 | |
| const div_content = $$CrE({ | |
| tagName: 'div', | |
| classes: 'plus-main', | |
| styles: { | |
| width: '960px', | |
| height: `${ document.body.scrollHeight }px`, | |
| }, | |
| }); | |
| fixed_div.append(div_content); | |
| document.body.append(fixed_div); | |
| // fixed_div内部再创建遮罩层,通过和文库.main相同方式定位到横向中心 | |
| // mask_container放在等高矩形下面,纵向位置上相当于标准文档流的末尾 | |
| const mask_container = $$CrE({ | |
| tagName: 'div', | |
| classes: ['plus-main'], | |
| attrs: { | |
| id: 'plus-background-mask-container' | |
| }, | |
| styles: { | |
| position: 'relative', | |
| height: '0' | |
| } | |
| }); | |
| // 遮罩层根据mask_container定位,横向定位在考虑过滚动条的中心,纵向从-5000vh开始,高度10000vh,覆盖全屏幕高度 | |
| const mask = $$CrE({ | |
| tagName: 'div', | |
| attrs:{ | |
| id: 'plus-background-mask', | |
| }, | |
| styles: { | |
| position: 'absolute', | |
| bottom: '-500000vh', | |
| left: '0', | |
| width: '960px', | |
| height: '1000000vh', | |
| zIndex: '-1', | |
| backdropFilter: mask_blur ? 'blur(10px)' : 'none', | |
| } | |
| }); | |
| mask_container.append(mask); | |
| fixed_div.append(img, mask_container); | |
| addStyle(` | |
| /* 网页自带背景调成透明 */ | |
| body:is(.plus-darkmode, :not(.plus-darkmode)):not(#StrongerThanDarkmode) { | |
| background-color: transparent; | |
| } | |
| :is(body.plus-darkmode, body:not(.plus-darkmode)) :is(table.grid td, .blockcontent, .even, .odd) { | |
| background: transparent !important; | |
| } | |
| .plus-main{ | |
| width: 960px; | |
| clear: both; | |
| text-align: center; | |
| margin-left: auto; | |
| margin-right: auto; | |
| margin-top:3px; | |
| } | |
| #plus-background-mask { | |
| background: var(--plus-background-mask-light); | |
| } | |
| .plus-darkmode #plus-background-mask { | |
| background: var(--plus-background-mask-dark); | |
| } | |
| `, 'plus-background-style'); | |
| addStyle(` | |
| :root { | |
| --plus-background-mask-light: rgba(255, 255, 255, ${mask_opacity}); | |
| --plus-background-mask-dark: rgba(0, 0, 0, ${mask_opacity}); | |
| } | |
| `, 'plus-background-style-adjust'); | |
| }, | |
| update(url, mask_opacity, image_fit, mask_blur) { | |
| $('#plus-background-img').src = url; | |
| $('#plus-background-img').style.objectFit = image_fit; | |
| $('#plus-background-mask').style.backdropFilter = mask_blur ? 'blur(10px)' : 'none'; | |
| addStyle(` | |
| :root { | |
| --plus-background-mask-light: rgba(255, 255, 255, ${mask_opacity}); | |
| --plus-background-mask-dark: rgba(0, 0, 0, ${mask_opacity}); | |
| } | |
| `, 'plus-background-style-adjust'); | |
| }, | |
| uninstall() { | |
| $('#plus-background-img')?.remove(); | |
| $('#plus-background-mask-positioner')?.remove(); | |
| $('#plus-background-style')?.remove(); | |
| }, | |
| }, | |
| color: { | |
| /** | |
| * @param {string} color | |
| */ | |
| install(color) { | |
| document.body.append($$CrE({ | |
| tagName: 'div', | |
| attrs: { | |
| id: 'plus-background-block', | |
| }, | |
| styles: { | |
| position: 'fixed', | |
| left: '0', | |
| top: '0', | |
| width: '100vw', | |
| height: '100vh', | |
| backgroundColor: color, | |
| zIndex: '-1', | |
| }, | |
| })); | |
| addStyle(` | |
| /* 网页自带背景调成透明 */ | |
| body:is(.plus-darkmode, :not(.plus-darkmode)):not(#StrongerThanDarkmode) { | |
| background-color: transparent; | |
| } | |
| :is(body.plus-darkmode, body:not(.plus-darkmode)) :is(table.grid td, .blockcontent, .even, .odd) { | |
| background: transparent !important; | |
| } | |
| `, 'plus-background-style'); | |
| }, | |
| update(color) { | |
| $('#plus-background-block').style.background = color; | |
| }, | |
| uninstall() { | |
| $('#plus-background-block')?.remove(); | |
| $('#plus-background-style')?.remove(); | |
| } | |
| } | |
| }; | |
| applyBackground(); | |
| // 注册设置,设置切换时实时应用 | |
| const Settings = CONST.Text.Background.Settings; | |
| configs.registerConfig('background', { | |
| GM_addValueChangeListener, | |
| items: [{ | |
| type: 'boolean', | |
| label: Settings.Enabled, | |
| caption: Settings.EnabledCaption, | |
| key: 'enabled', | |
| get() { return GM_getValue('enabled'); }, | |
| set(val) { GM_setValue('enabled', val); }, | |
| }, { | |
| type: 'select', | |
| label: Settings.Type, | |
| options: Settings.Types, | |
| key: 'type', | |
| get() { return GM_getValue('type'); }, | |
| set(val) { GM_setValue('type', val); }, | |
| }, { | |
| type: 'string', | |
| label: Settings.ImageUrl, | |
| key: 'image_url', | |
| get() { return GM_getValue('image_url'); }, | |
| set(val) { GM_setValue('image_url', val); }, | |
| }, { | |
| type: 'image', | |
| label: Settings.Image, | |
| key: 'image', | |
| callback: applyBackground, | |
| reload: 'page', | |
| async get() { | |
| // 从 OPFS:%Module%//background/image 中取出blob | |
| const root = await utils.getModuleDir('background'); | |
| let has_image = false; | |
| for await (const key of root.keys()) { | |
| if (key === 'image') { | |
| has_image = true; | |
| break; | |
| } | |
| } | |
| if (has_image) { | |
| const image = await root.getFileHandle('image', { create: true }); | |
| const file = await image.getFile(); | |
| return file; | |
| } else { | |
| return null; | |
| } | |
| }, | |
| /** | |
| * @param {File} file | |
| */ | |
| async set(file) { | |
| // 写入到 OPFS:%Module%//background/image | |
| const root = await utils.getModuleDir('background'); | |
| const image = await root.getFileHandle('image', { create: true }); | |
| const writable = await image.createWritable({ keepExistingData: false, mode: 'exclusive' }); | |
| const buffer = await file.arrayBuffer(); | |
| await writable.write(buffer); | |
| await writable.close(); | |
| }, | |
| }, { | |
| type: 'range', | |
| label: Settings.MaskOpacity, | |
| range: { | |
| max: 1, | |
| min: 0, | |
| step: 0.05, | |
| }, | |
| key: 'mask_opacity', | |
| get() { return GM_getValue('mask_opacity'); }, | |
| set(val) { GM_setValue('mask_opacity', val); }, | |
| }, { | |
| type: 'boolean', | |
| label: Settings.MaskBlur, | |
| key: 'mask_blur', | |
| get() { return GM_getValue('mask_blur'); }, | |
| set(val) { GM_setValue('mask_blur', val); }, | |
| }, { | |
| type: 'color', | |
| label: Settings.Color, | |
| key: 'color', | |
| get() { return GM_getValue('color'); }, | |
| set(val) { GM_setValue('color', val); }, | |
| }, { | |
| type: 'choose', | |
| label: Settings.ImageFit, | |
| options: Settings.ImageFitOptions, | |
| key: 'image_fit', | |
| get() { return GM_getValue('image_fit'); }, | |
| set(val) { GM_setValue('image_fit', val); }, | |
| }], | |
| label: Settings.Title, | |
| listeners: applyBackground, | |
| }); | |
| /** | |
| * 根据设置应用背景 | |
| */ | |
| async function applyBackground() { | |
| // 如果未启用背景功能,卸载现有背景并退出 | |
| if (!GM_getValue('enabled')) { | |
| cur_bg !== null && cur_bg.uninstall(); | |
| cur_bg = null; | |
| return; | |
| } | |
| // 目前应使用的背景类型及对应的背景管理器 | |
| /** @type {BGType} */ | |
| const type = GM_getValue('type'); | |
| const new_bg = ({ | |
| 'url': BG.image, | |
| 'local': BG.image, | |
| 'color': BG.color, | |
| }) [type]; | |
| // 传递给背景管理器的参数 | |
| /** @type {any[]} */ | |
| let args = []; | |
| switch (type) { | |
| case 'url': | |
| args = [ | |
| GM_getValue('image_url'), | |
| GM_getValue('mask_opacity'), | |
| GM_getValue('image_fit'), | |
| ]; | |
| break; | |
| case 'local': { | |
| const root = await utils.getModuleDir('background'); | |
| const image = await root.getFileHandle('image', { create: true }); | |
| const file = await image.getFile(); | |
| const url = URL.createObjectURL(file); | |
| args = [ | |
| url, | |
| GM_getValue('mask_opacity'), | |
| GM_getValue('image_fit'), | |
| GM_getValue('mask_blur'), | |
| ]; | |
| break; | |
| } | |
| case 'color': | |
| args = [GM_getValue('color')]; | |
| break; | |
| } | |
| // 如果背景类型不变,调用更新方法,否则卸载当前背景,安装新背景 | |
| if (cur_bg === new_bg) { | |
| new_bg.update.apply(null, args); | |
| } else { | |
| cur_bg && cur_bg.uninstall(); | |
| new_bg.install.apply(null, args); | |
| } | |
| // 更新当前背景管理器 | |
| cur_bg = new_bg; | |
| } | |
| }, | |
| }, | |
| openlastpage: { | |
| desc: '书评打开尾页', | |
| async func() { | |
| // 添加按钮的页面 | |
| const working_pages = [ | |
| // 书籍信息页 | |
| { | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, | |
| { | |
| type: 'path', | |
| value: '/modules/article/articleinfo.php' | |
| }, | |
| // 书评列表页 | |
| { | |
| type: 'path', | |
| value: '/modules/article/reviews.php' | |
| }, | |
| { | |
| type: 'path', | |
| value: '/modules/article/reviewslist.php' | |
| }, | |
| ]; | |
| // 添加[打开尾页]按钮 | |
| FunctionLoader.testCheckers(working_pages) && detectDom({ | |
| selector: 'a[href*="/modules/article/reviewshow.php"]', | |
| /** | |
| * @param {HTMLAnchorElement} a | |
| */ | |
| callback(a) { | |
| if (a.pathname !== '/modules/article/reviewshow.php') { return; } | |
| a.before($$CrE({ | |
| tagName: 'span', | |
| props: { | |
| innerText: CONST.Text.OpenLastPage.OpenLastPageButton, | |
| }, | |
| styles: { | |
| color: 'var(--q-primary)', | |
| cursor: 'pointer', | |
| paddingRight: '0.3em', | |
| }, | |
| listeners: [['click', e => { | |
| const str_rid = new URLSearchParams(a.search).get('rid'); | |
| window.open(`/modules/article/reviewshow.php?rid=${ str_rid }&page=last`); | |
| }]], | |
| })); | |
| } | |
| }); | |
| // | |
| }, | |
| }, | |
| styling: { | |
| desc: '样式管理器', | |
| disabled: true, | |
| detectDom: 'head', | |
| dependencies: ['utils', 'configs'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {configs} */ | |
| const configs = require('configs'); | |
| // 控制性样式表,用于对文库自带样式表进行一一对应地覆盖 | |
| // 格式:Record<文库自带样式表相对路径, 样式表内容> | |
| const ControllingStyleSheets = { | |
| '/themes/wenku8/style.css': `:root{--plus-bg-1:white;--plus-text-1:black;--plus-anchor:#4a4a4a;--plus-anchor-hover:#0033ff;--plus-border:#a4cded;--plus-border-light:#a3bee8;--plus-border-dialog:#8bcee4;--plus-bg-th-caption:#e9f1f8;--plus-bg-blocktitle:#d1e4fd;--plus-bgimg-caption:url("/themes/wenku8/image/caption_bg.gif");--plus-text-input:#054e86;--plus-text-th:#054e86;--plus-text-th-withbgimg:#0049a0;--plus-bg-button:#ddf2ff;--plus-bgimg-wrapper:url("/themes/wenku8/image/tabbg1_1.gif");--plus-bgimg-mtop:url("/themes/wenku8/image/m_top_bg.gif");--plus-bgimg-txt:url("/themes/wenku8/image/title_l.gif");--plus-bgimg-txtr:url("/themes/wenku8/image/title_r.gif");--plus-bgimg-blocktitle:url("/themes/wenku8/image/title_bg.gif");--plus-bgimg-nav:url("/themes/wenku8/image/nav_bg.png");--plus-bgimg-userinfo:url("/themes/wenku8/image/userinfo.gif");--plus-bg-2:#f0f7ff;--plus-pagelink-strong:#ff6600;--plus-text-ultop:#1b74bc;--plus-underline-ultop:#d8e4ef;--plus-text-poptext:#c42205;--plus-text-hottext:#ff0000;--plus-text-notetext:#1979cc;--plus-border-jieqi:#000000;--plus-bg-jieqi:#a4cded;--plus-text-nav:#fff;--plus-bg-mask:#777777;--plus-bg-dialog:#f1f5fa}body{background:var(--plus-bg-1)}a{color:var(--plus-anchor)}a:hover{color:var(--plus-anchor-hover)}hr{border:1px solid var(--plus-border)}table.grid{border:1px solid var(--plus-border)}table.grid caption,.gridtop{border:1px solid var(--plus-border);background:var(--plus-bg-th-strong);background-image:var(--plus-bgimg-caption);color:var(--plus-text-th)}table.grid th,.head{border:1px solid var(--plus-border);background:var(--plus-bg-2);color:var(--plus-text-th)}table.grid td{border:1px solid var(--plus-border);background-color:var(--plus-bg-1)!important}.title{background:var(--plus-bg-th-caption);color:var(--plus-text-th)}.even{background:var(--plus-bg-1)}.odd{background:var(--plus-bg-1)}.foot{background:var(--plus-bg-2)}.bottom{background:#b7b785}.text{border:1px solid var(--plus-border);background:var(--plus-bg-1);color:var(--plus-text-input);height:18px}.textarea{border:1px solid var(--plus-border);background:var(--plus-bg-1);color:var(--plus-text-input)}.button{background:var(--plus-bg-button);border:1px solid var(--plus-border);height:20px}#wrapper{background:var(--plus-bgimg-wrapper)}.m_top{background-image:var(--plus-bgimg-mtop)}.m_menu{background:#55a0ff;border-top:1px solid #e4e4e4;border-bottom:1px solid #e4e4e4}.m_foot{border-top:1px dashed var(--plus-border);border-bottom:1px dashed var(--plus-border)}.blocktop{border:1px solid var(--plus-border)}.blockcaption{background:var(--plus-bg-th-caption);background-image:var(--plus-bgimg-caption);color:var(--plus-text-th)}.block{border:1px solid var(--plus-border)}.blocktitle{border-top:2px solid var(--plus-bg-1);border-bottom:1px solid var(--plus-bg-1);border-left:2px solid var(--plus-bg-1);border-right:1px solid var(--plus-bg-1);background:var(--plus-bg-blocktitle);color:var(--plus-text-th)}.blockcontent{border-top:1px solid var(--plus-border-light);padding:3px}.blockcontenttop{border-top:1px solid var(--plus-border-light);border-bottom:1px solid var(--plus-border-light);padding:3px}.blocknote{border-top:1px solid var(--plus-border);background:var(--plus-bg-2)}.blocktitle span0{border-top:1px solid var(--plus-border);border-left:1px solid var(--plus-border);border-right:1px solid var(--plus-border);background:var(--plus-bg-1);color:var(--plus-text-poptext)}.blocktitle .txt{background-image:var(--plus-bgimg-txt);color:var(--plus-text-th-withbgimg)}.blocktitle .txtr{background-image:var(--plus-bgimg-txtr)}.gameblocktop{border:1px solid var(--plus-border)}.gameblockcontent{border-top:1px solid var(--plus-border-light)}.appblocktop{border:1px solid var(--plus-border)}.appblockcaption{background:var(--plus-bg-th-caption);background-image:var(--plus-bgimg-caption);color:var(--plus-text-th)}.appblockcontent{border-top:1px solid var(--plus-border-light)}#left .blocktitle,#right .blocktitle{background-image:var(--plus-bgimg-blocktitle)}#left .blockcontent,#right .blockcontent{background:var(--plus-bg-1)}.ultop li{border-bottom:1px dashed var(--plus-underline-ultop);color:var(--plus-text-ultop)}.ultop li a{color:var(--plus-text-poptext)}.ultops li{border-bottom:1px dashed var(--plus-underline-ultop);color:var(--plus-text-ultop)}.ultops li a{color:var(--plus-text-poptext)}.hottext,a.hottext{color:var(--plus-text-hottext)}.poptext,a.poptext{color:var(--plus-text-poptext)}.notetext,a.notetext{color:var(--plus-text-notetext)}.errortext,a.errortext{color:var(--plus-text-hottext)}a.btnlink{color:#535353;background:var(--plus-bg-1);border:0 solid var(--plus-border)}a.btnlink:hover{background:var(--plus-bg-1)}a.btnlink1{color:#535353;background:var(--plus-bg-1);border:0 solid var(--plus-border)}a.btnlink1:hover{background:var(--plus-bg-1)}a.btnlink2{color:#535353;background:var(--plus-bg-button);border:1px solid var(--plus-border)}a.btnlink2:hover{background:#cccccc}.jieqiQuote,.jieqiCode,.jieqiNote{border:var(--plus-border-jieqi) 1px solid;color:var(--plus-text-1);background-color:var(--plus-bg-jieqi)}.divbox{border:1px solid var(--plus-border)}.textbox{border:1px solid var(--plus-border)}.popbox{border:1px solid var(--plus-border);background:var(--plus-bg-2);color:var(--plus-text-hottext)}#tips{border:1px solid var(--plus-border);background:var(--plus-bg-2)}.tablist li a{background:var(--plus-bg-2);color:var(--plus-text-1);border:1px solid var(--plus-border)}.tablist li a.selected{background:var(--plus-bg-1)}.tabcontent{border:1px solid var(--plus-border)}.pagelink{border:1px solid var(--plus-border);background:var(--plus-bg-2)}.pagelink a:hover{background-color:var(--plus-bg-1)}.pagelink strong{color:var(--plus-pagelink-strong);background:var(--plus-bg-th-caption)}.pagelink kbd{border-left:1px solid var(--plus-border)}.pagelink em{border-right:1px solid var(--plus-border)}.pagelink input{border:1px solid var(--plus-border);color:var(--plus-text-input)}.nav{background:var(--plus-bgimg-nav) no-repeat 0 -36px}.navinner{background:var(--plus-bgimg-nav) no-repeat 100% -72px}.navlist{background:var(--plus-bgimg-nav) repeat-x 0 0}.nav li{background:var(--plus-bgimg-nav) no-repeat 0 -108px}.nav a:link,.nav a:visited{color:var(--plus-text-nav);text-decoration:none}.nav a.current,.nav a:hover,.nav a:active{color:var(--plus-text-nav);background:var(--plus-bgimg-nav) no-repeat 50% -144px}.subnav{background:var(--plus-bgimg-nav) no-repeat 0 -180px}.subnav p{background:var(--plus-bgimg-nav) no-repeat 100% -234px}.subnav p span{background:var(--plus-bgimg-nav) repeat-x 0 -207px}.subnav p.pointer{background:var(--plus-bgimg-nav) repeat-x 0 -261px}.subnav,.subnav a:link,.subnav a:visited{color:#235e99}.subnav a:hover,.subnav a:active{color:#235e99}.ajaxtip{border:1px solid var(--plus-border-light);background:var(--plus-bg-2);color:var(--plus-text-hottext)}#tips{border:1px solid var(--plus-border-light);background:var(--plus-bg-2)}#dialog{border:5px solid var(--plus-border-dialog);background:var(--plus-bg-dialog)}#mask{background:var(--plus-bg-mask)}.userinfo_001{background:var(--plus-bgimg-userinfo) 0 0 no-repeat}.userinfo_002{background:var(--plus-bgimg-userinfo) 0px -16px no-repeat}.userinfo_003{background:var(--plus-bgimg-userinfo) 0px -34px no-repeat}.userinfo_004{background:var(--plus-bgimg-userinfo) 0px -54px no-repeat}.userinfo_005{background:var(--plus-bgimg-userinfo) 0px -73px no-repeat}.userinfo_006{background:var(--plus-bgimg-userinfo) 0px -94px no-repeat}.userinfo_007{background:var(--plus-bgimg-userinfo) 0px -113px no-repeat}.userinfo_008{background:var(--plus-bgimg-userinfo) 0px -133px no-repeat}img.avatars{border:1px solid #dddddd}`, | |
| '/configs/article/page.css': ``, | |
| }; | |
| GM_getValue = utils.defaultedGet({ | |
| }, GM_getValue); | |
| install(); | |
| /** | |
| * 安装所有控制性样式表到页面 | |
| */ | |
| function install() { | |
| Array.from($All('link[rel="stylesheet"][href]')).forEach(link => { | |
| const pathname = new URL(link.href).pathname; | |
| const id = `plus-styling-${pathname}`.replaceAll('/', '_'); | |
| ControllingStyleSheets.hasOwnProperty(pathname) && | |
| addStyle(ControllingStyleSheets[pathname], id); | |
| }); | |
| } | |
| } | |
| }, | |
| blocking: { | |
| desc: '屏蔽功能', | |
| disabled: false, | |
| dependencies: ['dependencies', 'utils', 'configs', 'mousetip'], | |
| params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], | |
| /** @typedef {Awaited<ReturnType<typeof functions.blocking.func>>} blocking */ | |
| async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { | |
| /** @type {utils} */ | |
| const utils = require('utils'); | |
| /** @type {configs} */ | |
| const configs = require('configs'); | |
| /** @type {mousetip} */ | |
| const mousetip = require('mousetip'); | |
| /** | |
| * @typedef {Object} BlockUserInfo | |
| * @property {string} username | |
| * @property {string} avatar | |
| * @property {number} time_added | |
| */ | |
| /** | |
| * @typedef {Object} BlockBookInfo | |
| * @property {string} name | |
| * @property {string} cover | |
| * @property {number} time_added | |
| */ | |
| /** @typedef {BlockUserInfo | BlockBookInfo} BlockInfo */ | |
| /** | |
| * @typedef {Object} BlockTarget | |
| * @property {'user' | 'book'} type | |
| * @property {number} id | |
| * @property {BlockInfo} info | |
| */ | |
| GM_getValue = utils.defaultedGet({ | |
| /** @type {BlockTarget[]} */ | |
| blocklist: [], | |
| /** @type {boolean} */ | |
| enabled: true, | |
| }, GM_getValue); | |
| const Settings = CONST.Text.Blocking.Settings; | |
| configs.registerConfig('blocking', { | |
| label: Settings.Label, | |
| items: [{ | |
| type: 'boolean', | |
| label: Settings.Enabled, | |
| caption: Settings.EnabledCaption, | |
| reload: true, | |
| key: 'enabled', | |
| get() { return GM_getValue('enabled'); }, | |
| set(val) { return GM_setValue('enabled', val); }, | |
| }, { | |
| type: 'button', | |
| label: Settings.BlockList, | |
| button_icon: 'edit_note', | |
| button_label: Settings.BlockListEdit, | |
| callback() { gui.show(); }, | |
| }], | |
| GM_addValueChangeListener | |
| }) | |
| const pool_funcs = { | |
| userblock: { | |
| desc: '屏蔽用户', | |
| async func() { | |
| const pool_funcs = { | |
| bookreviewlist: { | |
| desc: '书籍信息页和书籍书评列表页的书评屏蔽', | |
| checkers: [ | |
| // 书籍信息页 | |
| { | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, | |
| { | |
| type: 'path', | |
| value: '/modules/article/articleinfo.php' | |
| }, | |
| // 书籍书评列表页 | |
| { | |
| type: 'path', | |
| value: '/modules/article/reviews.php' | |
| } | |
| ], | |
| func() { | |
| if (!GM_getValue('enabled')) { return } | |
| addStyle(` | |
| .plus-blocking-blocked { | |
| display: none; | |
| } | |
| `, 'plus-blocking'); | |
| detectDom({ | |
| selector: 'table.grid td:nth-of-type(3) > a[href*="userpage.php"]', | |
| callback: a => dealBlocking(a) | |
| }); | |
| GM_addValueChangeListener('blocklist', (key, old_val, new_val, remote) => { | |
| // 当屏蔽状态改变时,改变书评条目的隐藏/显示状态 | |
| Array.from($All('table.grid td:nth-of-type(3) > a[href*="userpage.php"]')).forEach(a => dealBlocking(a)); | |
| }); | |
| /** | |
| * 给定书评条目中的用户链接元素,根据屏蔽状态隐藏/显示此书评条目 | |
| * @param {HTMLAnchorElement} a | |
| */ | |
| function dealBlocking(a) { | |
| const uid = parseInt(new URLSearchParams(a.search).get('uid'), 10); | |
| userBlocked(uid) ? | |
| a.closest('tr').classList.add('plus-blocking-blocked') : | |
| a.closest('tr').classList.remove('plus-blocking-blocked'); | |
| } | |
| }, | |
| }, | |
| reviewlist: { | |
| desc: '书评列表页书评屏蔽', | |
| checkers: { | |
| type: 'path', | |
| value: '/modules/article/reviewslist.php' | |
| }, | |
| func() { | |
| if (!GM_getValue('enabled')) { return } | |
| addStyle(` | |
| .plus-blocking-blocked { | |
| display: none; | |
| } | |
| `, 'plus-blocking'); | |
| detectDom({ | |
| selector: 'table.grid td:nth-of-type(4) > a[href*="userpage.php"]', | |
| callback: a => dealBlocking(a) | |
| }); | |
| GM_addValueChangeListener('blocklist', (key, old_val, new_val, remote) => { | |
| // 当屏蔽状态改变时,改变书评条目的隐藏/显示状态 | |
| Array.from($All('table.grid td:nth-of-type(4) > a[href*="userpage.php"]')).forEach(a => dealBlocking(a)); | |
| }); | |
| /** | |
| * 给定书评条目中的用户链接元素,根据屏蔽状态隐藏/显示此书评条目 | |
| * @param {HTMLAnchorElement} a | |
| */ | |
| function dealBlocking(a) { | |
| const uid = parseInt(new URLSearchParams(a.search).get('uid'), 10); | |
| userBlocked(uid) ? | |
| a.closest('tr').classList.add('plus-blocking-blocked') : | |
| a.closest('tr').classList.remove('plus-blocking-blocked'); | |
| } | |
| } | |
| }, | |
| userpage: { | |
| desc: '用户主页', | |
| checkers: { | |
| type: 'path', | |
| value: '/userpage.php' | |
| }, | |
| async func() { | |
| if (!GM_getValue('enabled')) { return } | |
| /** @type {userpage} */ | |
| const userpage = await require('userpage', true); | |
| const page = userpage.PageManager.page; | |
| const uid = parseInt(new URLSearchParams(location.search).get('uid'), 10); | |
| const username = (await detectDom('#left > div.block:first-of-type .ulrow > li > strong')).innerText; | |
| const avatar = (await detectDom('#left > div.block:first-of-type .ulrow > li > img')).src; | |
| makeButton(); | |
| makeLine(); | |
| GM_addValueChangeListener('blocklist', (key, old_val, new_val, remote) => { | |
| // 当屏蔽状态改变时,重新制作屏蔽/解除屏蔽按钮 | |
| if (isBlocked(uid, 'user', old_val) !== isBlocked(uid, 'user', new_val)) { | |
| makeButton(); | |
| makeLine(); | |
| } | |
| }); | |
| /** | |
| * 根据目前屏蔽状态,(重新)安装屏蔽/解除屏蔽按钮 | |
| */ | |
| function makeButton() { | |
| const time_added = Date.now(); | |
| userpage.PageManager.transformer.removeUserButton(page, 'block'); | |
| userpage.PageManager.transformer.addUserButton(page, { | |
| id: 'block', | |
| label: userBlocked(uid) ? CONST.Text.Blocking.UnBlockUser : CONST.Text.Blocking.BlockUser, | |
| index: 3, | |
| callback: () => userBlocked(uid) ? unBlockUser(uid) : blockUser(uid, { username, avatar, time_added }), | |
| }); | |
| } | |
| /** | |
| * 根据目前屏蔽状态,添加/移除屏蔽提示 | |
| */ | |
| function makeLine() { | |
| userBlocked(uid) ? | |
| userpage.PageManager.transformer.addUserLine(page, { | |
| id: 'block', | |
| line: CONST.Text.Blocking.UserBlocked, | |
| index: 2, | |
| }) : | |
| userpage.PageManager.transformer.removeLine(page, 'block'); | |
| } | |
| } | |
| } | |
| }; | |
| const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); | |
| await promise; | |
| } | |
| }, | |
| bookblock: { | |
| desc: '屏蔽书籍', | |
| async func() { | |
| const pool_funcs = { | |
| blocktoggle: { | |
| desc: '书籍信息页屏蔽/解除屏蔽功能', | |
| checkers: [{ | |
| type: 'regpath', | |
| value: /\/book\/\d+\.htm/ | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/articleinfo.php' | |
| }], | |
| async func() { | |
| /** @type {sidepanel} */ | |
| const sidepanel = await require('sidepanel', true); | |
| const aid = parseInt(new URLSearchParams(location.search).get('id') | |
| ?? location.pathname.match(/\/book\/(\d+)\.htm/)[1], 10); | |
| const name = (await detectDom('#content > div:first-of-type > table table span > b')).innerText; | |
| const cover = (await detectDom('#content > div:first-of-type > table:last-of-type img')).src; | |
| // 屏蔽按钮 | |
| GM_getValue('enabled') && sidepanel.registerButton({ | |
| id: 'bookblock.block', | |
| label: 'Button Label to be updated', | |
| icon: 'icon to be updated', // hourglass_top // 沙漏图标也许可用来占位 | |
| index: 6, | |
| callback() { | |
| let blocked = bookBlocked(aid); | |
| blocked ? unBlockBook(aid) : blockBook(aid, { name, cover, time_added: Date.now() }); | |
| blocked = !blocked; | |
| updateSideButton(); | |
| const notify_message = replaceText( | |
| blocked ? CONST.Text.Blocking.BlockedBook : CONST.Text.Blocking.UnBlockedBook, { | |
| '{Name}': name | |
| }); | |
| Quasar.Notify.create({ | |
| type: 'success', | |
| message: notify_message, | |
| group: 'blocking.book.toggle' | |
| }); | |
| } | |
| }); | |
| updateSideButton(); | |
| GM_addValueChangeListener('blocklist', (key, old_val, new_val, remote) => updateSideButton()); | |
| // 本书被屏蔽文字提示 | |
| await blockTip(); | |
| GM_addValueChangeListener('blocklist', async (key, old_val, new_val, remote) => await blockTip()); | |
| /** 根据目前书籍屏蔽状态更新按钮外观 */ | |
| function updateSideButton() { | |
| const isBlocked = bookBlocked(aid); | |
| sidepanel.updateButton('bookblock.block', { | |
| label: isBlocked ? CONST.Text.Blocking.UnBlockBook : CONST.Text.Blocking.BlockBook, | |
| icon: isBlocked ? 'do_not_disturb_off' : 'block', | |
| }); | |
| } | |
| /** 本书被屏蔽文字提示 */ | |
| async function blockTip() { | |
| if (bookBlocked(aid)) { | |
| const span = utils.html2elm(`<span class="text-primary" id="plus-blocktip" style="font-size:13px;"><br><b>${ CONST.Text.Blocking.BookBlocked }</b></span>`); | |
| const parent = await detectDom('#content > div:first-of-type > table:nth-of-type(2) td:first-of-type'); | |
| $('#plus-blocktip')?.remove(); | |
| parent.append(span); | |
| } else { | |
| $('#plus-blocktip')?.remove(); | |
| } | |
| } | |
| } | |
| }, | |
| searchtoggle: { | |
| desc: '书籍列表页屏蔽/解除屏蔽功能', | |
| checkers: [{ | |
| type: 'path', | |
| value: '/modules/article/articlelist.php' | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/toplist.php' | |
| }, { | |
| type: 'path', | |
| value: '/modules/article/search.php' | |
| }], | |
| async func() { | |
| GM_getValue('enabled') && [...$All('#content > table:last-of-type td > div > div > a')].forEach(a => { | |
| /** @type {HTMLDivElement} */ | |
| const container = a.parentElement.parentElement; | |
| const aid = parseInt(a.pathname.match(/\d+/)[0], 10); | |
| updateButton(); | |
| GM_addValueChangeListener('blocklist', (key, old_val, new_val, remote) => updateButton()); | |
| function updateButton() { | |
| const Blocking = CONST.Text.Blocking; | |
| const isBlocked = bookBlocked(aid); | |
| const button_text = isBlocked ? Blocking.UnBlockBook : Blocking.BlockBook; | |
| // 按钮存在,则修改现有按钮;如果按钮不存在,则创建按钮 | |
| let button = $(container, '.plus-blocking-button'); | |
| if (button) { | |
| button.innerText = button_text; | |
| } else { | |
| button = $$CrE({ | |
| tagName: 'span', | |
| props: { | |
| innerText: button_text, | |
| }, | |
| styles: { | |
| color: 'var(--q-primary)', | |
| cursor: 'pointer', | |
| }, | |
| classes: ['plus-blocking-butto |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment