[Neko0] VRChat 无限虚拟形象收藏夹

不止300个!将您的VRChat Avatar虚拟形象收藏夹扩展到无限!

  1. // ==UserScript==
  2. // @name [Neko0] VRChat Limitless Favorite Avatar
  3. // @name:zh-CN [Neko0] VRChat 无限虚拟形象收藏夹
  4. // @name:ja [Neko0] VRChat 無制限の「Avatar」ブックマークフォルダ
  5. // @description More than 300! Expand your VRChat avatar collection to infinity!
  6. // @description:zh-CN 不止300个!将您的VRChat Avatar虚拟形象收藏夹扩展到无限!
  7. // @description:ja 300以上!あなたのVRChatアバターコレクションを無限に拡張しましょう!
  8. // @version 1.2.1
  9. // @author Mitsuki Joe
  10. // @namespace neko0-web-tools
  11. // @icon https://assets.vrchat.com/www/favicons/favicon.ico
  12. // @homepageURL https://github.com/nekozero/neko0-web-tools
  13. // @supportURL https://t.me/+FANQrUGRV7A0YmM9
  14. // @grant GM_addStyle
  15. // @grant GM_setValue
  16. // @grant GM_getValue
  17. // @grant GM_xmlhttpRequest
  18. // @grant GM_getResourceText
  19. // @run-at document-idle
  20. // @license AGPL-3.0-or-later
  21. // @require https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.8.1/js/solid.min.js
  22. // @require https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.8.1/js/fontawesome.min.js
  23. // @require https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.min.js
  24. // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.0/jquery.min.js
  25. // @require https://cdn.jsdelivr.net/npm/axios@1.1.3/dist/axios.min.js
  26. // @require https://cdn.jsdelivr.net/npm/vue@2.7.14
  27. // @require https://cdn.jsdelivr.net/npm/vue-lazyload@1.3.3/vue-lazyload.min.js
  28. // @require https://unpkg.com/@popperjs/core@2
  29. // @require https://unpkg.com/tippy.js@6
  30. // @require https://cdn.jsdelivr.net/npm/alertifyjs@1.13.1/build/alertify.min.js
  31. // @resource IMPORTED_CSS_1 https://cdn.jsdelivr.net/npm/alertifyjs@1.13.1/build/css/alertify.rtl.min.css
  32. // @match *://vrchat.com/*
  33. // @resource IMPORTED_CSS_2 https://cdn.jsdelivr.net/gh/nekozero/neko0-web-tools@1.2.2/convenience/vrchat/style.css
  34. // @resource html-avatar-btn https://cdn.jsdelivr.net/gh/nekozero/neko0-web-tools@1.2.2/convenience/vrchat/html-avatar-btn.html
  35. // @resource html-avatar-list https://cdn.jsdelivr.net/gh/nekozero/neko0-web-tools@1.2.2/convenience/vrchat/html-avatar-list.html
  36. // @resource html-btn-group https://cdn.jsdelivr.net/gh/nekozero/neko0-web-tools@1.2.2/convenience/vrchat/html-btn-group.html
  37. // @resource language https://cdn.jsdelivr.net/gh/nekozero/neko0-web-tools@1.2.2/convenience/vrchat/language.json
  38. // ==/UserScript==
  39. /* jshint expr: true */
  40.  
  41. // #region 初始化设定
  42. // 设置项默认值
  43. let setting = {
  44. lang: 'en',
  45. }
  46.  
  47. // 判断是否存在设定
  48. if (GM_getValue('VLAF_setting') === undefined) {
  49. GM_setValue('VLAF_setting', setting)
  50. } else {
  51. let store = GM_getValue('VLAF_setting')
  52. $.each(setting, function (i) {
  53. if (store[i] === undefined) {
  54. store[i] = setting[i]
  55. }
  56. })
  57. GM_setValue('VLAF_setting', store)
  58. }
  59.  
  60. // 示例模型列表
  61. // prettier-ignore
  62. let avatars = [{"id":"avtr_bc6c06ec-fda2-4490-8db2-946f618dba2d","name":"贴贴小S-M子","description":"准备后续再更新几套衣服,还有道具,欢迎加群 80697161","authorId":"usr_6621262d-0005-4d37-8624-19ecd4c30a76","authorName":"铃月时雨","tags":[],"imageUrl":"https://api.vrchat.cloud/api/1/file/file_01d6551a-7cf6-4dcb-b93c-4aeaadba9815/2/file","thumbnailImageUrl":"https://api.vrchat.cloud/api/1/image/file_01d6551a-7cf6-4dcb-b93c-4aeaadba9815/2/256","releaseStatus":"public","version":30,"featured":false,"unityPackages":[{"id":"unp_8cbb9ab2-fa25-483f-8c73-850b8b993916","created_at":"2022-12-23T11:42:08.740Z","unityVersion":"2019.4.31f1c1","assetVersion":1,"platform":"standalonewindows","variant":"standard"},{"id":"unp_d1e81047-992a-4a3c-9959-c7dad0c3d246","created_at":"2023-01-04T03:08:05.858Z","assetVersion":1,"platform":"android","unityVersion":"2019.4.31f1c1","variant":"standard"}],"unityPackageUrl":"","unityPackageUrlObject":{},"created_at":"2022-12-23T11:42:08.741Z","updated_at":"2023-01-14T11:10:41.376Z","isPrivate":false,"isBroken":false,"fromWorld":{"id":"wrld_4d68dafc-8fb3-437d-9ed4-c1da29f27231","name":"M子模型房"},"labels":["Loli","Cute","Gun"],"addTime":"2022-05-01T00:00:00.000Z"},{"id":"avtr_a20ffadf-49e5-464a-86f7-c567c173d801","name":"M小龙娘ver2 -M子","description":"和ver1版本有点不一样(可能负优化所以分了两个版本,但是加了些别的功能)欢迎加群 80697161","authorId":"usr_6621262d-0005-4d37-8624-19ecd4c30a76","authorName":"铃月时雨","tags":[],"imageUrl":"https://api.vrchat.cloud/api/1/file/file_5141b6b1-c9f9-4fb2-ab33-f8a19af8fb43/2/file","thumbnailImageUrl":"https://api.vrchat.cloud/api/1/image/file_5141b6b1-c9f9-4fb2-ab33-f8a19af8fb43/2/256","releaseStatus":"public","version":11,"featured":false,"unityPackages":[{"id":"unp_1e6fe9b1-e269-4360-8ce2-966deba9f023","created_at":"2022-11-12T14:08:19.586Z","unityVersion":"2019.4.31f1c1","assetVersion":1,"platform":"standalonewindows","variant":"standard"},{"id":"unp_d8db6daf-72e3-4796-a839-12fdf51891f4","created_at":"2022-12-12T14:45:01.127Z","assetVersion":1,"platform":"android","unityVersion":"2019.4.31f1c1","variant":"standard"}],"unityPackageUrl":"","unityPackageUrlObject":{},"created_at":"2022-11-12T14:08:19.586Z","updated_at":"2023-01-02T14:13:50.273Z","isPrivate":false,"isBroken":false,"fromWorld":{"id":"wrld_4d68dafc-8fb3-437d-9ed4-c1da29f27231","name":"M子模型房"},"labels":[],"addTime":"2022-05-01T00:00:00.000Z"},{"id":"avtr_b895678c-690d-4163-8c7f-5c655c8a9492","name":"可爱小菲-M子","description":"加群加群,后续更新以后再说","authorId":"usr_6621262d-0005-4d37-8624-19ecd4c30a76","authorName":"铃月时雨","tags":[],"imageUrl":"https://api.vrchat.cloud/api/1/file/file_f0277fd4-6975-49c8-8dad-5fabefefc09c/2/file","thumbnailImageUrl":"https://api.vrchat.cloud/api/1/image/file_f0277fd4-6975-49c8-8dad-5fabefefc09c/2/256","releaseStatus":"public","version":12,"featured":false,"unityPackages":[{"id":"unp_93eabfce-7979-4c72-a0dd-527ce72e4ae3","created_at":"2023-03-26T08:57:49.023Z","unityVersion":"2019.4.31f1c1","assetVersion":1,"platform":"standalonewindows","variant":"standard"},{"id":"unp_7f09fccb-4997-4637-bcf1-f95d7a5c99c4","created_at":"2023-03-30T13:51:57.832Z","assetVersion":1,"platform":"android","unityVersion":"2019.4.31f1c1","variant":"standard"}],"unityPackageUrl":"","unityPackageUrlObject":{},"created_at":"2023-03-26T08:57:49.023Z","updated_at":"2023-04-14T10:46:13.121Z","isPrivate":false,"isBroken":false,"fromWorld":{"id":"wrld_4d68dafc-8fb3-437d-9ed4-c1da29f27231","name":"M子模型房"},"labels":["Loli"],"addTime":"2022-05-01T00:00:00.000Z"},{"id":"avtr_8b6da3f5-bd3d-4ce3-900e-7f92d40b554e","name":"M子小猫","description":"还是test阶段,有什么想加的功能可以喊我,交流群80697161","authorId":"usr_6621262d-0005-4d37-8624-19ecd4c30a76","authorName":"铃月时雨","tags":[],"imageUrl":"https://api.vrchat.cloud/api/1/file/file_416a230c-3d62-4af3-8856-5293490f2ae5/2/file","thumbnailImageUrl":"https://api.vrchat.cloud/api/1/image/file_416a230c-3d62-4af3-8856-5293490f2ae5/2/256","releaseStatus":"public","version":20,"featured":false,"unityPackages":[{"id":"unp_30d85467-9921-4c81-9338-926219c85d34","created_at":"2022-11-27T01:57:38.875Z","unityVersion":"2019.4.31f1c1","assetVersion":1,"platform":"standalonewindows","variant":"standard"},{"id":"unp_48a49a77-66f7-4e0c-b6f0-01e3346dae5d","created_at":"2022-12-11T13:08:59.003Z","assetVersion":1,"platform":"android","unityVersion":"2019.4.31f1c1","variant":"standard"}],"unityPackageUrl":"","unityPackageUrlObject":{},"created_at":"2022-11-27T01:57:38.875Z","updated_at":"2023-01-01T04:30:30.359Z","isPrivate":false,"isBroken":false,"fromWorld":{"id":"wrld_4d68dafc-8fb3-437d-9ed4-c1da29f27231","name":"M子模型房"},"labels":["Loli","Cute"],"addTime":"2022-05-01T00:00:00.000Z"},{"id":"avtr_3985e9d3-3af1-4f82-9b15-71d2d8ef02c5","name":"M理沙-M子","description":"密码加群哦~群号 80697161","authorId":"usr_6621262d-0005-4d37-8624-19ecd4c30a76","authorName":"铃月时雨","tags":[],"imageUrl":"https://api.vrchat.cloud/api/1/file/file_64c31eae-2df4-49a3-bf28-513ca0e66dd0/2/file","thumbnailImageUrl":"https://api.vrchat.cloud/api/1/image/file_64c31eae-2df4-49a3-bf28-513ca0e66dd0/2/256","releaseStatus":"public","version":23,"featured":false,"unityPackages":[{"id":"unp_99e840fd-962a-407c-954e-0f41edf173fe","created_at":"2023-01-26T14:07:58.703Z","unityVersion":"2019.4.31f1c1","assetVersion":1,"platform":"standalonewindows","variant":"standard"},{"id":"unp_3ef58cc9-6758-4960-adf1-7f9eada82539","created_at":"2023-02-22T14:43:58.158Z","assetVersion":1,"platform":"android","unityVersion":"2019.4.31f1c1","variant":"standard"}],"unityPackageUrl":"","unityPackageUrlObject":{},"created_at":"2023-01-26T14:07:58.704Z","updated_at":"2023-02-23T14:24:12.284Z","isPrivate":false,"isBroken":false,"fromWorld":{"id":"wrld_4d68dafc-8fb3-437d-9ed4-c1da29f27231","name":"M子模型房"},"labels":["Loli","Touhou"],"addTime":"2022-05-01T00:00:00.000Z"},{"id":"avtr_194c4d8e-58fe-408e-9c72-38b310250fde","name":"可爱小果冻 - M子","description":"随便改改,可以调发色大小之类的,大小需要重新载入下模型,有点bug,欢迎来群里玩 80697161","authorId":"usr_6621262d-0005-4d37-8624-19ecd4c30a76","authorName":"铃月时雨","tags":[],"imageUrl":"https://api.vrchat.cloud/api/1/file/file_ffeb727e-1fec-4376-98b0-7a801a0f6180/2/file","thumbnailImageUrl":"https://api.vrchat.cloud/api/1/image/file_ffeb727e-1fec-4376-98b0-7a801a0f6180/2/256","releaseStatus":"public","version":5,"featured":false,"unityPackages":[{"id":"unp_c8b796fa-7b07-40b1-ae3d-62dfe3e5ad05","created_at":"2022-09-30T12:54:22.956Z","unityVersion":"2019.4.31f1c1","assetVersion":1,"platform":"standalonewindows","variant":"standard"},{"id":"unp_371c8959-aca6-4f47-b1e0-3dfa434b7c62","created_at":"2022-12-11T14:22:30.390Z","assetVersion":1,"platform":"android","unityVersion":"2019.4.31f1c1","variant":"standard"}],"unityPackageUrl":"","unityPackageUrlObject":{},"created_at":"2022-09-30T12:54:22.956Z","updated_at":"2022-12-11T14:53:12.063Z","isPrivate":false,"isBroken":false,"fromWorld":{"id":"wrld_4d68dafc-8fb3-437d-9ed4-c1da29f27231","name":"M子模型房"},"labels":["Loli"],"addTime":"2022-05-01T00:00:00.000Z"}]
  63. if (GM_getValue('VLAF_avatars') === undefined) {
  64. GM_setValue('VLAF_avatars', avatars)
  65. }
  66. // #endregion
  67.  
  68. // #region 预设函数
  69. // 打印相关
  70. window.log = function (...args) {
  71. // if (ENV.PRODUCTION) {
  72. // return false
  73. // }
  74. // 设定样式
  75. let style = 'color:#fff;border-radius:4px;padding:2px 4px;'
  76. const colors = {
  77. log: '#39485c',
  78. warn: '#face51',
  79. error: '#ea3324',
  80. success: '#64b587',
  81. }
  82. const log_functions = {
  83. log: console.log,
  84. warn: console.warn,
  85. error: console.error,
  86. success: console.info,
  87. }
  88. // 设定打印内容
  89. if (args.length > 1 && args[0] in colors) {
  90. style += 'background:' + colors[args[0]] + ';'
  91. if (
  92. window.clientType === 'wechat' ||
  93. window.clientType === 'miniprogram' ||
  94. window.clientType === 'uapp' ||
  95. Window.clientType === 'screen'
  96. ) {
  97. let log_function = args[0] in log_functions ? log_functions[args[0]] : log_functions['log']
  98. log_function('[' + args[1] + ']', ...args.slice(2))
  99. } else {
  100. console.log('%c' + args[1], style, ...args.slice(2))
  101. }
  102. } else {
  103. console.log(...args)
  104. }
  105. }
  106. window.logRes = function (response) {
  107. // if (ENV.PRODUCTION) {
  108. // return false
  109. // }
  110. console.log('============')
  111. console.log('请求URL: ' + response.request.responseURL)
  112. console.log('返回Data: ')
  113. console.log(response.data)
  114. console.log('============')
  115. }
  116. window.logError = function (error, alert) {
  117. // if (ENV.PRODUCTION) {
  118. // return false
  119. // }
  120. console.log('============')
  121. console.log('请求数据: ')
  122. console.log(error.request)
  123. console.log('返回Error: ')
  124. console.log(error)
  125. console.log('返回Data: ')
  126. console.log(error.response)
  127. console.log('============')
  128. }
  129.  
  130. // 提示框位置
  131. alertify.set('notifier', 'position', 'top-center')
  132.  
  133. // 实时获取最新设置
  134. let getSet = () => {
  135. return GM_getValue('VLAF_setting')
  136. }
  137. // 更改设置
  138. let setSet = (key, value) => {
  139. let store = GM_getValue('VLAF_setting')
  140. store[key] = value
  141. GM_setValue('VLAF_setting', store)
  142. }
  143. // 实时获取最新模型列表
  144. let getAvtrs = () => {
  145. return GM_getValue('VLAF_avatars')
  146. }
  147. log('success', '模型列表', getAvtrs())
  148.  
  149. // 文本内容多语言替换
  150. let text = JSON.parse(GM_getResourceText('language'))[getSet().lang]
  151. log('success', '当前设定', getSet())
  152. log('success', '语言文本', text)
  153.  
  154. // 置入Style
  155. GM_addStyle(GM_getResourceText('IMPORTED_CSS_1'))
  156. GM_addStyle(GM_getResourceText('IMPORTED_CSS_2'))
  157.  
  158. // 正则替换DOM内“变量”
  159. // From: https://gist.github.com/cybercase/2298e242e82d32b15787
  160. if (!String.prototype.format) {
  161. String.prototype.format = function (dict) {
  162. return this.replace(/{(\w+)}/g, function (match, key) {
  163. return typeof dict[key] !== 'undefined' ? dict[key] : match
  164. })
  165. }
  166. }
  167.  
  168. // 格式化当前时间
  169. let getNowDate = () => {
  170. // 定义一个函数来补齐两位数
  171. function pad(num) {
  172. return num < 10 ? '0' + num : num
  173. }
  174.  
  175. // 获取当前时间的 Date 对象
  176. let date = new Date()
  177.  
  178. // 获取年月日时分秒毫秒
  179. let year = date.getFullYear()
  180. let month = pad(date.getMonth() + 1)
  181. let day = pad(date.getDate())
  182. let hour = pad(date.getHours())
  183. let minute = pad(date.getMinutes())
  184. let second = pad(date.getSeconds())
  185. let millisecond = pad(date.getMilliseconds())
  186.  
  187. // 拼接成 2022-07-19T20:50:50.033Z 这种格式
  188. let formatted = `${year}-${month}-${day}T${hour}:${minute}:${second}.${millisecond}Z`
  189.  
  190. // 打印结果
  191. return formatted
  192. }
  193.  
  194. // 分页功能
  195. let pagination = function (currentPage, itemsPerPage, array) {
  196. var offset = (currentPage - 1) * itemsPerPage
  197. return offset + itemsPerPage >= array.length
  198. ? array.slice(offset, array.length)
  199. : array.slice(offset, offset + itemsPerPage)
  200. }
  201.  
  202. // Detect error types
  203. let detectError = msg => {
  204. log('error', 'ERROR', msg)
  205. if (msg == "You already have 50 favorite avatars in group 'avatars1'") return alertify.error(text.avatars_full)
  206. if (msg == 'You already have that avatar favorited') return alertify.error(text.avatars_added)
  207. if (msg == 'This avatar is unavailableǃ') return alertify.error(text.error_unavailable)
  208. if (msg == "avatar isn't public and avatar is also not owned by you") return alertify.error(text.error_private)
  209. if (msg == "Can't find avatarǃ" || msg == 'Avatar Not Found') return alertify.error(text.error_deleted)
  210. return alertify.error(text.operation_failed)
  211. }
  212.  
  213. // #endregion
  214.  
  215. // #region 左侧导航栏
  216. ;(function () {
  217. // 置入DOM
  218. function domBtnGroup() {
  219. let html = GM_getResourceText('html-btn-group')
  220. let output = html.format(text)
  221. $('.leftbar .btn-group-vertical').prepend(output)
  222. }
  223.  
  224. // 检测页面内容置入插件DOM
  225. let timer_left = setInterval(detection, 300)
  226. detection()
  227. function detection() {
  228. var neko0 = document.querySelector('.limitless')
  229. if (!neko0) {
  230. domBtnGroup()
  231. } else {
  232. clearInterval(timer_left)
  233. alertify.success(text.mounted)
  234. }
  235. }
  236.  
  237. // 检测页面内容置入插件DOM
  238. let timer_class = setInterval(detection_class, 100)
  239. detection_class()
  240. function detection_class() {
  241. // 获取具有 title="locations" 属性的元素
  242. if ($('[title="download"]').length > 0 && $('.limitless').length > 0) {
  243. // 获取第三个到最后一个类名
  244. var classNamesArray = $('[title="download"]').attr('class').split(' ')
  245. var startIndex = 2 // 第三个类名的索引
  246. var endIndex = classNamesArray.length // 最后一个类名的索引
  247.  
  248. // 提取第三个到最后一个类名并拼接成字符串
  249. var extractedClassNames = classNamesArray.slice(startIndex, endIndex).join(' ')
  250. log('log', 'extractedClassNames', extractedClassNames)
  251. // 将提取的类名添加到 .limitless 元素上
  252. $('.limitless').eq(0).addClass(extractedClassNames)
  253. $('.limitless').eq(0).css('display', 'flex')
  254. clearInterval(timer_class)
  255. }
  256. }
  257.  
  258. // 绑定点击事件
  259. // 打开设置窗口
  260. // $('.n-box .button.switch').click(() => {
  261. // $('.n-box').toggleClass('open')
  262. // })
  263.  
  264. // setTimeout(() => {
  265. // alertify.success("You've clicked OK")
  266. // window.alertify = alertify
  267. // }, 1000)
  268. })()
  269. // #endregion
  270.  
  271. // #region 功能函数
  272. // 判断已收藏
  273. let isInVLAF = avtr_id => {
  274. let store = getAvtrs()
  275. return store.find(obj => obj.id === avtr_id)
  276. }
  277. // 马上切换
  278. let select = avtr_id => {
  279. url = window.location.origin + '/api/1/avatars/' + avtr_id + '/select'
  280. axios
  281. .put(url)
  282. .then(function (response) {
  283. log('success', 'ƒ Select', response)
  284. alertify.success(text.operation_succeeded)
  285. })
  286. .catch(function (error) {
  287. log('error', 'ƒ Select', error)
  288. detectError(error.response.data.error.message)
  289. })
  290. .finally(function () {})
  291. }
  292. // 收藏到系统收藏夹
  293. let favorites = avtr_id => {
  294. url = window.location.origin + '/api/1/favorites'
  295. val = {
  296. type: 'avatar',
  297. favoriteId: avtr_id,
  298. tags: ['avatars1'],
  299. }
  300. axios
  301. .post(url, val)
  302. .then(function (response) {
  303. log('success', 'ƒ Favorites', response)
  304. alertify.success(text.operation_succeeded)
  305. })
  306. .catch(function (error) {
  307. log('error', 'ƒ Favorites', error)
  308. detectError(error.response.data.error.message)
  309. })
  310. .finally(function () {})
  311. }
  312. // 收藏到无限收藏夹
  313. let limitless = avtr_id => {
  314. log('log', 'ƒ Limitless', 'START')
  315. url = window.location.origin + '/api/1/avatars/' + avtr_id
  316.  
  317. let store = getAvtrs()
  318. const result = isInVLAF(avtr_id)
  319.  
  320. if (result) {
  321. log('log', 'ƒ Limitless', 'Avatar 已存在')
  322. store = store.filter(function (obj) {
  323. return obj.id !== avtr_id
  324. })
  325. GM_setValue('VLAF_avatars', store)
  326. if ($('#collect').length) {
  327. $('#collect').removeClass('text-danger confirm-delete').children('button').removeClass('border-danger')
  328. $('#collect span').eq(0).text(text.btn_collect)
  329. }
  330. } else {
  331. log('log', 'ƒ Limitless', 'Avatar 不存在')
  332. let data = null
  333. axios
  334. .get(url)
  335. .then(function (response) {
  336. log('success', 'ƒ Limitless', response)
  337. alertify.success(text.operation_succeeded)
  338. data = response.data
  339. data.isPrivate = false
  340. data.isBroken = false
  341. data.fromWorld = {
  342. id: '',
  343. name: '',
  344. }
  345. data.labels = []
  346. data.addTime = getNowDate()
  347. store.push(data)
  348. GM_setValue('VLAF_avatars', store)
  349. if ($('#collect').length) {
  350. $('#collect').addClass('text-danger').children('button').addClass('border-danger')
  351. $('#collect span').eq(0).text(text.btn_collect_r)
  352. }
  353. })
  354. .catch(function (error) {
  355. log('error', 'ƒ Limitless', error)
  356. })
  357. .finally(function () {})
  358. }
  359. }
  360. // #endregion
  361.  
  362. // #region 不同页面匹配逻辑
  363. let page_is_avtr_own = () => {
  364. return document.location.pathname === '/home/avatars'
  365. }
  366. let page_is_avtr_details = () => {
  367. return document.location.pathname.indexOf('/home/avatar/avtr_') !== -1
  368. }
  369. let page_is_limitless = () => {
  370. return document.location.pathname === '/home/limitless'
  371. }
  372. let page_is_favorite_avtr = () => {
  373. return document.location.pathname.includes('/home/favorites/avatar')
  374. }
  375. // #endregion
  376.  
  377. let pluginInject = () => {
  378. if (!page_is_limitless() && $('.neko0.limitless-list.row')[0]) {
  379. $('.neko0.limitless-list.row')[0].remove()
  380. }
  381. if (page_is_avtr_own()) {
  382. // #region 个人Avatar页
  383. log('log', '个人Avatar页', 'START')
  384. // 当前使用Avatar
  385. // let current_avtr_id = document.querySelector('[data-scrollkey]').getAttribute('data-scrollkey')
  386. // console.log(current_avtr_id)
  387. // let current_avtr_info = null
  388. // ;(function () {
  389. // url =
  390. // 'https://vrchat.com/api/1/users/' +
  391. // document.querySelector('[aria-label="User Status"]').getAttribute('href').substring(11) +
  392. // '/avatar'
  393. // axios
  394. // .get(url)
  395. // .then(function (response) {
  396. // console.log(response)
  397. // current_avtr_info = response.data
  398. // })
  399. // .catch(function (error) {
  400. // console.log(error)
  401. // })
  402. // .finally(function () {
  403.  
  404. // })
  405. // })()
  406. // 算了暂时先不改这个
  407. // endregion
  408. } else if (page_is_avtr_details()) {
  409. // #region Avatar详情页
  410. // 当前浏览Avatar
  411. let current_avtr_id = window.location.pathname.substring(13)
  412. log('log', 'Avatar详情页', 'START', isInVLAF(current_avtr_id), getAvtrs())
  413.  
  414. // 置入DOM
  415. let domAvatar = function () {
  416. let html = GM_getResourceText('html-avatar-btn')
  417. let output = html.format(text)
  418.  
  419. $("h4:contains('Manage Avatar')").next().attr('id', 'neko0').prepend(output)
  420. // .children('div:nth-child(1)')
  421. // .remove()
  422.  
  423. // 获取第下面 div 的 class 并复制到第一个 div
  424. var secondDivClass = $('#neko0 div:nth-child(3)').attr('class')
  425. $('#collect').attr('class', secondDivClass)
  426.  
  427. // 获取第下面 div 里面 button 的第三和第四个 class
  428. var buttonClasses = $('#neko0 div:nth-child(3) button').attr('class').split(' ')
  429. var thirdAndFourthClass = buttonClasses.slice(0, 1).join(' ')
  430.  
  431. // 将第三和第四个 class 复制给第一个 div 里面的 button
  432. $('#collect button').addClass(thirdAndFourthClass)
  433.  
  434. if (isInVLAF(current_avtr_id)) {
  435. $('#collect').addClass('text-danger').children('button').addClass('border-danger')
  436. $('#collect span').eq(0).text(text.btn_collect_r)
  437. }
  438.  
  439. tippy('#collect', {
  440. content: text.tippy_collect,
  441. })
  442.  
  443. $('#collect').click(() => {
  444. const item = $('#collect')
  445. console.log(item)
  446. console.log(item.hasClass('text-danger') && !item.hasClass('confirm-delete'))
  447.  
  448. if (item.hasClass('text-danger') && !item.hasClass('confirm-delete')) {
  449. item.addClass('confirm-delete')
  450. item.removeClass('text-danger')
  451. $('#collect span').eq(0).text(text.confirm_delete)
  452. console.log(4)
  453. } else {
  454. limitless(current_avtr_id)
  455. }
  456. })
  457. $('.confirm-delete-cancel').click(() => {
  458. $('#collect')
  459. .removeClass('confirm-delete')
  460. .addClass('text-danger')
  461. .children('button')
  462. .addClass('border-danger')
  463. $('#collect span').eq(0).text(text.btn_collect_r)
  464. })
  465. }
  466.  
  467. // 检测页面内容置入插件DOM
  468. new MutationObserver((_, observer) => {
  469. if ($("h4:contains('Manage Avatar')").length > 0 && !$('#neko0').length) {
  470. domAvatar()
  471. // 调整布局让按钮更方便点击
  472. $('.container > div.tw-mb-3').eq(0).prependTo('.container > div.tw-flex-wrap > div:first-child')
  473. $('.home-content').addClass('page_is_avtr_details')
  474. observer.disconnect()
  475. }
  476. }).observe(document.body, { childList: true, subtree: true })
  477.  
  478. log('log', 'Avatar详情页', 'END')
  479. // #endregion
  480. } else if (page_is_limitless()) {
  481. // #region 无限Avatar页面
  482. log('log', '无限Avatar页面', getAvtrs(), pagination(2, 12, getAvtrs()))
  483.  
  484. // 置入DOM
  485. let domLimitless = function () {
  486. let html = GM_getResourceText('html-avatar-list')
  487. let output = html
  488. $('.home-content').append(output)
  489.  
  490. Vue.use(VueLazyload, {
  491. preLoad: 1.6,
  492. attempt: 1,
  493. adapter: {
  494. error(listender, Init) {
  495. log('error', 'error', $(listender.el).attr('img-avtr-id'), listender.el, listender, Init)
  496. // 追加标识
  497. listender.el.parentElement.parentElement.parentElement.classList.add('broken')
  498. // 更改描述
  499. listender.el.parentElement.parentElement.nextElementSibling.querySelector(
  500. '.description .value'
  501. ).textContent = text.broken_description
  502. },
  503. loaded({ bindType, el, naturalHeight, naturalWidth, $parent, src, loading, error, Init }) {
  504. // 删除标识
  505. el.parentElement.parentElement.parentElement.classList.remove('broken')
  506. },
  507. },
  508. })
  509.  
  510. new Vue({
  511. el: '#neko0',
  512. data: {
  513. text: text,
  514. searchQuery: '', // 用户输入的搜索内容
  515. selectedSearchFields: ['name'], // 默认搜索 name 字段
  516. itemsAll: [],
  517. items: [],
  518. // 排序顺序
  519. order: 'desc',
  520. // 排序字段
  521. sortBy: 'addTime',
  522. // 选择的标签
  523. selectedTags: [],
  524. // 是否仅安卓平台
  525. androidOnly: false,
  526. // 标签列表
  527. allTags: ['tag1', 'tag2', 'tag3'],
  528. selectedTags: [],
  529. // 分页
  530. currentPage: 1,
  531. itemsPerPage: 12,
  532. totalPages: 0,
  533. },
  534. methods: {
  535. // 语言切换
  536. languageSwitch: function () {
  537. getSet().lang === 'en' ? setSet('lang', 'zh_cn') : setSet('lang', 'en')
  538. text = JSON.parse(GM_getResourceText('language'))[getSet().lang]
  539. location.reload()
  540. },
  541.  
  542. // 格式化时间
  543. getFormattedDate: () => {
  544. // 使用现有的补齐两位数的函数
  545. function pad(num) {
  546. return num < 10 ? '0' + num : num
  547. }
  548.  
  549. // 获取当前时间的 Date 对象
  550. let date = new Date()
  551.  
  552. // 获取年月日时分
  553. let year = date.getFullYear()
  554. let month = pad(date.getMonth() + 1)
  555. let day = pad(date.getDate())
  556. let hour = pad(date.getHours())
  557. let minute = pad(date.getMinutes())
  558.  
  559. // 拼接成 YYYY-MM-DD-HH-mm 这种格式
  560. let formatted = `${year}${month}${day}-${hour}${minute}`
  561.  
  562. // 返回结果
  563. return formatted
  564. },
  565.  
  566. // 导出导入
  567. exportList: function () {
  568. // 将 JSON 数据转换为字符串
  569. const jsonString = JSON.stringify(getAvtrs())
  570. // 创建一个 Blob 对象
  571. const blob = new Blob([jsonString], { type: 'application/json' })
  572. // 创建一个下载链接
  573. const link = document.createElement('a')
  574. link.href = URL.createObjectURL(blob)
  575. const time = this.getFormattedDate()
  576. link.download = `LimitlessAvatars-${time}.json`
  577. // 模拟点击链接下载文件
  578. document.body.appendChild(link)
  579. link.click()
  580. },
  581. importList: function () {
  582. log('log', 'ƒ importList')
  583. const fileInput = document.getElementById('file-input')
  584. fileInput.click()
  585. },
  586. fileUpload: function () {
  587. log('log', 'ƒ fileUpload', 'start')
  588. const fileInput = document.getElementById('file-input')
  589. const file = fileInput.files[0]
  590. const reader = new FileReader()
  591.  
  592. reader.onload = event => {
  593. const fileContent = event.target.result
  594. const jsonData = JSON.parse(fileContent)
  595.  
  596. log('log', 'ƒ fileUpload', 'import:', jsonData)
  597.  
  598. const A = getAvtrs()
  599. const B = jsonData
  600.  
  601. const diff = _.differenceBy(B, A, 'id')
  602. const merge = _.concat(A, diff)
  603.  
  604. log('log', 'ƒ fileUpload', 'merge:', merge)
  605.  
  606. GM_setValue('VLAF_avatars', merge)
  607. this.items = merge
  608. }
  609.  
  610. reader.readAsText(file)
  611. },
  612.  
  613. // 格式化时间
  614. formattedDate: function (str) {
  615. const dateStr = str
  616. const date = new Date(dateStr)
  617. const year = date.getFullYear()
  618. const month = date.getMonth() + 1
  619. const day = date.getDate()
  620. const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${day
  621. .toString()
  622. .padStart(2, '0')}`
  623.  
  624. // log('log', 'ƒ formattedDate', formattedDate)
  625. return formattedDate
  626. },
  627. hasWindows: function (obj) {
  628. // 定义一个变量来存储检查结果
  629. let hasWindows = false
  630.  
  631. // 遍历对象中的 unityPackages 数组
  632. for (let package of obj.unityPackages) {
  633. // 如果某个元素的 platform 属性等于 standalonewindows,就将结果设为 true,并跳出循环
  634. if (package.platform === 'standalonewindows') {
  635. hasWindows = true
  636. break
  637. }
  638. }
  639.  
  640. return hasWindows
  641. },
  642. hasAndroid: function (obj) {
  643. return _.some(
  644. obj.unityPackages,
  645. pkg => pkg.platform === 'android' && pkg.variant === 'standard'
  646. )
  647. },
  648. favorites: function (avtr_id) {
  649. favorites(avtr_id)
  650. },
  651. select: function (avtr_id) {
  652. select(avtr_id)
  653. },
  654. limitless: function (event, avtr_id) {
  655. const button = event.currentTarget
  656. console.log(button)
  657. console.log(
  658. button.classList.contains('text-danger') && !button.classList.contains('confirm-delete')
  659. )
  660.  
  661. if (button.classList.contains('text-danger') && !button.classList.contains('confirm-delete')) {
  662. button.classList.add('confirm-delete')
  663. button.classList.remove('text-danger')
  664. button.querySelector('span').textContent = text.confirm_delete
  665. console.log(4)
  666. } else {
  667. this.limitless_doit(button, avtr_id)
  668. }
  669. },
  670. limitless_doit: function (button, avtr_id) {
  671. // 执行删除数据操作
  672. limitless(avtr_id)
  673. // 在页面上刪除该元素
  674. button.closest('.avatar-li').remove()
  675. },
  676. limitless_cancel: function () {
  677. $('.confirm-delete').removeClass('confirm-delete').addClass('text-danger border-danger')
  678. $('.confirm-delete span').eq(0).text(text.btn_collect_r)
  679. },
  680.  
  681. // #region 搜索栏函数
  682.  
  683. searchData: function (searchQuery, searchFields = ['name']) {
  684. // 如果没有输入搜索关键词,则返回原始数据
  685. if (!searchQuery.trim()) {
  686. this.currentPage = 1 // 重置当前页数
  687. this.loadPageData()
  688. this.updateTotalPages()
  689. return
  690. }
  691.  
  692. // 搜索条件的字段(默认是name)
  693. const fields = ['name', ...searchFields]
  694.  
  695. // 使用lodash过滤数据
  696. const result = _.filter(getAvtrs(), item => {
  697. // 遍历字段进行匹配
  698. return fields.some(field => {
  699. if (item[field] && item[field].toLowerCase().includes(searchQuery.toLowerCase())) {
  700. return true // 如果匹配成功,返回true
  701. }
  702. return false
  703. })
  704. })
  705.  
  706. return result
  707. },
  708.  
  709. // 搜索框输入时的事件处理
  710. onSearchChange: function () {
  711. // 去掉多余的空格
  712. this.searchQuery = this.searchQuery.trim()
  713. // 调用搜索函数更新数据
  714. this.searchAndUpdate()
  715. },
  716.  
  717. // 多选框变化时的事件处理
  718. onSearchFieldsChange: function () {
  719. // 如果字段选择变化,重新更新搜索
  720. this.searchAndUpdate()
  721. },
  722.  
  723. // 调用搜索功能并更新分页
  724. searchAndUpdate: function () {
  725. // 执行搜索
  726. let searchResults = this.searchData(this.searchQuery, this.selectedSearchFields)
  727.  
  728. // 更新结果并分页
  729. this.itemsAll = searchResults
  730. this.currentPage = 1 // 重置当前页数
  731. this.loadPageData(this.itemsAll)
  732. this.updateTotalPages()
  733. },
  734. // #endregion
  735.  
  736. // #region 筛选栏函数
  737. /**
  738. * 排序函数
  739. * @param {Array} data - 数据数组
  740. * @param {String} order - 排序顺序 ('asc'或'desc')
  741. * @param {String} sortBy - 排序字段 ('addTime'、'updated_at'、'name'、'authorName')
  742. * @return {Array} - 排序后的数据
  743. */
  744. sortData: function (data, order = 'asc', sortBy = 'addTime') {
  745. return _.orderBy(data, [sortBy], [order])
  746. },
  747.  
  748. /**
  749. * 分类筛选函数
  750. * @param {Array} data - 数据数组
  751. * @param {Array} tags - 需要包含的tag列表
  752. * @return {Array} - 筛选后的数据
  753. */
  754. filterByTags: function (data, tags = []) {
  755. if (tags.length === 0) return data // 如果没有选择tag,则返回全部数据
  756. return _.filter(data, item => _.intersection(item.tags, tags).length > 0)
  757. },
  758.  
  759. /**
  760. * 安卓平台筛选函数
  761. * @param {Array} data - 数据数组
  762. * @param {Boolean} isAndroidSelected - 是否筛选安卓平台
  763. * @return {Array} - 筛选后的数据
  764. */
  765. filterByPlatform: function (data, isAndroidSelected = false) {
  766. if (!isAndroidSelected) return data // 如果未勾选安卓筛选,则返回全部数据
  767. return _.filter(data, item =>
  768. _.some(item.unityPackages, pkg => pkg.platform === 'android' && pkg.variant === 'standard')
  769. )
  770. },
  771.  
  772. /**
  773. * 组合函数:按条件筛选和排序数据
  774. * @param {Array} data - 数据数组
  775. * @param {Object} options - 筛选和排序选项
  776. * @param {Array} options.tags - 多选的tags筛选列表
  777. * @param {Boolean} options.isAndroidSelected - 是否筛选安卓平台
  778. * @param {String} options.order - 排序顺序 ('asc'或'desc')
  779. * @param {String} options.sortBy - 排序字段 ('addTime'、'updated_at'、'name'、'authorName')
  780. * @return {Array} - 经过筛选和排序后的数据
  781. */
  782. getFilteredAndSortedData: function (data, options = {}) {
  783. const { tags = [], isAndroidSelected = false, order = 'asc', sortBy = 'addTime' } = options
  784.  
  785. let result = _.cloneDeep(data)
  786.  
  787. // 按tags筛选
  788. result = this.filterByTags(result, tags)
  789.  
  790. // 按平台筛选
  791. result = this.filterByPlatform(result, isAndroidSelected)
  792.  
  793. // 排序
  794. result = this.sortData(result, order, sortBy)
  795.  
  796. return result
  797. },
  798.  
  799. loadPageData: function (list = getAvtrs()) {
  800. options = {
  801. tags: this.selectedTags, // 根据需要选择tag
  802. isAndroidSelected: this.androidOnly, // 筛选安卓平台数据
  803. order: this.order, // 排列
  804. sortBy: this.sortBy, // 排序
  805. }
  806. let res = this.getFilteredAndSortedData(list, options)
  807. this.itemsAll = res
  808. console.log('getFilteredAndSortedData', res)
  809. this.items = pagination(this.currentPage, this.itemsPerPage, res)
  810. },
  811.  
  812. // #endregion
  813.  
  814. // #region 分页功能
  815. // 更新总页数
  816. updateTotalPages() {
  817. this.totalPages = Math.ceil(this.itemsAll.length / this.itemsPerPage)
  818. if (this.currentPage > this.totalPages) this.currentPage = this.totalPages
  819. },
  820. // 改变页码
  821. changePage(page) {
  822. if (page >= 1 && page <= this.totalPages) {
  823. this.currentPage = page
  824. }
  825. },
  826. // 上一页
  827. prevPage() {
  828. if (this.currentPage > 1) {
  829. this.currentPage--
  830. }
  831. },
  832. // 下一页
  833. nextPage() {
  834. if (this.currentPage < this.totalPages) {
  835. this.currentPage++
  836. }
  837. },
  838. // 改变每页显示数量
  839. onItemsPerPageChange() {
  840. this.currentPage = 1 // 改变显示数量后回到第一页
  841. this.updateTotalPages()
  842. },
  843. // 分页函数(根据原始分页函数修改)
  844. pagination(currentPage, itemsPerPage, array) {
  845. const offset = (currentPage - 1) * itemsPerPage
  846. return offset + itemsPerPage >= array.length
  847. ? array.slice(offset, array.length)
  848. : array.slice(offset, offset + itemsPerPage)
  849. },
  850.  
  851. // #endregion
  852. },
  853.  
  854. watch: {
  855. // 监听
  856. // 监听 selectedTags 的变化
  857. selectedTags: function () {
  858. this.loadPageData()
  859. this.updateTotalPages()
  860. },
  861. // 监听 androidOnly 的变化
  862. androidOnly: function () {
  863. this.loadPageData()
  864. this.updateTotalPages()
  865. },
  866. // 监听 order 的变化
  867. order: function () {
  868. this.loadPageData()
  869. this.updateTotalPages()
  870. },
  871. // 监听 sortBy 的变化
  872. sortBy: function () {
  873. this.loadPageData()
  874. this.updateTotalPages()
  875. },
  876. // 监听 currentPage 的变化
  877. currentPage: function () {
  878. this.loadPageData(this.itemsAll)
  879. },
  880. itemsPerPage() {
  881. this.loadPageData(this.itemsAll)
  882. this.updateTotalPages()
  883. },
  884. },
  885.  
  886. created: function () {
  887. let _this = this
  888. window.add_data = _this.add_data
  889. },
  890. computed: {
  891. // 可见的页码列表
  892. visiblePages() {
  893. const pages = []
  894. const startPage = Math.max(1, this.currentPage - 2)
  895. const endPage = Math.min(this.totalPages, this.currentPage + 2)
  896.  
  897. for (let i = startPage; i <= endPage; i++) {
  898. pages.push(i)
  899. }
  900. return pages
  901. },
  902. },
  903.  
  904. mounted() {
  905. tippy('.transmit', {
  906. content: text.tippy_transmit,
  907. })
  908. tippy('.use', {
  909. content: text.tippy_use,
  910. })
  911. tippy('.collect', {
  912. content: text.tippy_collect,
  913. })
  914. tippy('.export', {
  915. content: text.tippy_export,
  916. })
  917. tippy('.import', {
  918. content: text.tippy_import,
  919. })
  920. tippy('.minus-five', {
  921. content: text.minus_five,
  922. })
  923. tippy('.plus-five', {
  924. content: text.plus_five,
  925. })
  926.  
  927. // 获取模型列表
  928. this.loadPageData()
  929. this.updateTotalPages()
  930. },
  931. })
  932. }
  933.  
  934. // 检测页面内容置入插件DOM
  935. let detection = function () {
  936. var neko0 = document.querySelector('.neko0')
  937. if (!neko0) {
  938. domLimitless()
  939. } else {
  940. clearInterval(timer)
  941. }
  942. }
  943. let timer = setInterval(detection, 300)
  944. detection()
  945. log('log', '无限Avatar页面', 'END')
  946. // #endregion
  947. } else if (page_is_favorite_avtr()) {
  948. // #region 系统Avatar收藏夹
  949. log('log', '系统Avatar收藏夹', 'START')
  950.  
  951. function checkForAvatarCard() {
  952. const avatarCardElements = document.querySelectorAll('[aria-label="Avatar Card"]')
  953. if (avatarCardElements.length > 0) {
  954. // 存在 Avatar Card 元素,执行后续内容
  955. clearInterval(checkInterval) // 停止定时检测
  956. log('log', '个人Avatar页', '已经加载 Avatar Card,执行后续内容')
  957.  
  958. // 获取页面中所有具有 aria-label="Avatar Card" 的元素
  959. const avatarCards = document.querySelectorAll('[aria-label="Avatar Card"]')
  960.  
  961. // 给定的数组
  962. const dataArray = getAvtrs()
  963. // 遍历页面中的元素
  964. avatarCards.forEach(avatarCard => {
  965. // 获取当前元素的 data-scrollkey 值
  966. const scrollKey = avatarCard.getAttribute('data-scrollkey')
  967.  
  968. // 检查 scrollKey 是否存在于给定数组中的对象 id 属性中
  969. const match = dataArray.some(item => item.id === scrollKey)
  970.  
  971. // 如果匹配成功,则添加类名 "in-vlaf"
  972. if (match) {
  973. // 获取[aria-label="Avatar Image"]子元素
  974. const avatarImage = avatarCard.querySelector('[aria-label="Avatar Image"]')
  975.  
  976. // 检查是否找到 avatarImage 元素
  977. if (avatarImage) {
  978. // 如果找到 avatarImage 元素,则添加类名 "in-vlaf"
  979. avatarImage.classList.add('in-vlaf')
  980. }
  981. }
  982. })
  983. } else {
  984. log('log', '个人Avatar页', '尚未加载出 Avatar Card')
  985. }
  986. }
  987.  
  988. // 设置定时检测的时间间隔(毫秒)
  989. const checkIntervalMs = 1000 // 1秒钟
  990. const checkInterval = setInterval(checkForAvatarCard, checkIntervalMs)
  991.  
  992. // 初始时执行一次检测
  993. checkForAvatarCard()
  994.  
  995. log('log', '系统Avatar收藏夹', 'END')
  996. // #endregion
  997. }
  998. }
  999.  
  1000. pluginInject()
  1001.  
  1002. // 监测页面变换
  1003. const _historyWrap = function (type) {
  1004. const orig = history[type]
  1005. const e = new Event(type)
  1006. return function () {
  1007. const rv = orig.apply(this, arguments)
  1008. e.arguments = arguments
  1009. window.dispatchEvent(e)
  1010. return rv
  1011. }
  1012. }
  1013. history.pushState = _historyWrap('pushState')
  1014. history.replaceState = _historyWrap('replaceState')
  1015. window.addEventListener('pushState', function (e) {
  1016. log('log', 'change pushState')
  1017. pluginInject()
  1018. })
  1019. window.addEventListener('replaceState', function (e) {
  1020. log('log', 'change replaceState')
  1021. })